From 6d498ccf2004edf2a43f12fd8941b80cc212ab4a Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 21 Jul 2025 15:35:50 +0200 Subject: [PATCH] Primerize the top bar user dropdown --- app/views/custom_styles/_inline_css.erb | 1 - config/locales/en.yml | 1 + .../src/app/core/top-menu/top-menu.service.ts | 185 +----------------- .../revit-add-in-settings-button.service.ts | 2 +- .../global_styles/common/header/app-menu.sass | 80 -------- lib/redmine/menu_manager/menu_helper.rb | 49 ----- lib/redmine/menu_manager/top_menu_helper.rb | 78 +++++--- spec/support/components/menu/dropdown.rb | 10 +- .../support/components/menu/quick_add_menu.rb | 4 + 9 files changed, 65 insertions(+), 345 deletions(-) diff --git a/app/views/custom_styles/_inline_css.erb b/app/views/custom_styles/_inline_css.erb index 3f05cecab67..bc174fa26c7 100644 --- a/app/views/custom_styles/_inline_css.erb +++ b/app/views/custom_styles/_inline_css.erb @@ -79,7 +79,6 @@ See COPYRIGHT and LICENSE files for more details. DesignColor.overwritten.select { |design_color| design_color.variable == 'header-bg-color' }.try(:first).try(:hexcode) == '#FFFFFF' && DesignColor.overwritten.select { |design_color| design_color.variable == 'header-item-bg-hover-color' }.try(:first).try(:hexcode) == '#FFFFFF' %> <%# Disable hover background color to not overlap border %> - .op-app-menu--item-action:hover, .top-menu-search.-collapsed .top-menu-search--button:hover { background: transparent; } diff --git a/config/locales/en.yml b/config/locales/en.yml index 957d8081361..258107f1a96 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3293,6 +3293,7 @@ en: label_user_mail_option_only_my_events: "Only for things I watch or I'm involved in" label_user_mail_option_only_owner: "Only for things I am the owner of" label_user_mail_option_selected: "For any event on the selected projects only" + label_user_menu: "Open user navigation menu" label_user_new: "New user" label_user_plural: "Users" label_user_search: "Search for user" diff --git a/frontend/src/app/core/top-menu/top-menu.service.ts b/frontend/src/app/core/top-menu/top-menu.service.ts index 227095b0886..36299fdcfc6 100644 --- a/frontend/src/app/core/top-menu/top-menu.service.ts +++ b/frontend/src/app/core/top-menu/top-menu.service.ts @@ -25,46 +25,23 @@ // // See COPYRIGHT and LICENSE files for more details. //++ -import { findAllFocusableElementsWithin } from 'core-app/shared/helpers/focus-helpers'; -import { Inject, Injectable } from '@angular/core'; +import { + Inject, + Injectable, +} from '@angular/core'; import { DOCUMENT } from '@angular/common'; -import { BehaviorSubject, Observable } from 'rxjs'; export const ANIMATION_RATE_MS = 100; @Injectable({ providedIn: 'root' }) export class TopMenuService { - private hover = false; - - private menuIsOpen = false; - - private menuContainer:HTMLElement; - - private active$ = new BehaviorSubject(null); - constructor(@Inject(DOCUMENT) private document:Document) { } register():void { - this.menuContainer = this.document.querySelector('.op-app-header') as HTMLElement; - this.setupDropdownClick(); - this.closeOnBodyClick(); - this.accessibility(); this.skipContentClickListener(); } - public activeDropdown$():Observable { - return this.active$.asObservable(); - } - - // the entire menu gets closed, no hover possible afterwards - public close():void { - this.stopHover(); - this.closeAllItems(); - this.menuIsOpen = false; - this.active$.next(null); - } - private skipContentClickListener():void { // Skip menu on content const skipLink = this.document.querySelector('#skip-navigation--content') as HTMLElement; @@ -80,158 +57,4 @@ export class TopMenuService { } }); } - - private accessibility():void { - this - .document - .querySelectorAll('.op-app-menu--dropdown') - .forEach((el) => el.setAttribute('aria-expanded', 'false')); - } - - private toggleClick(dropdown:HTMLElement):void { - if (this.menuIsOpen) { - if (this.isOpen(dropdown)) { - this.close(); - } else { - this.openDropdown(dropdown); - } - } else { - this.opening(); - this.openDropdown(dropdown); - } - } - - // somebody opens the menu via click, hover possible afterwards - private opening():void { - this.startHover(); - this.menuIsOpen = true; - } - - private stopHover():void { - this.hover = false; - this.menuContainer?.classList.remove('hover'); - } - - private startHover():void { - this.hover = true; - this.menuContainer?.classList.add('hover'); - } - - private closeAllItems():void { - this - .openDropdowns() - .forEach((item) => this.closeDropdown(item)); - } - - private closeOnBodyClick():void { - const wrapper = document.getElementById('wrapper'); - if (!wrapper) { - return; - } - - wrapper.addEventListener('click', (evt) => { - if (this.menuIsOpen && !this.openDropdowns()[0]?.contains(evt.target as HTMLElement)) { - this.close(); - } - }, true); - } - - private openDropdowns():HTMLElement[] { - const elements = this.menuContainer?.querySelectorAll('.op-app-menu--item_dropdown-open'); - return elements ? Array.from(elements) : []; - } - - private dropdowns():HTMLElement[] { - const elements = this.menuContainer?.querySelectorAll('.op-app-menu--item_has-dropdown'); - return elements ? Array.from(elements) : []; - } - - private setupDropdownClick():void { - this.dropdowns().forEach((el) => { - const action = el.querySelector('.op-app-menu--item-action'); - action?.addEventListener('click', (evt) => { - this.toggleClick(el); - evt.preventDefault(); - }); - }); - } - - private isOpen(dropdown:HTMLElement):boolean { - return dropdown.classList.contains('.op-app-menu--item_dropdown-open'); - } - - private isClosed(dropdown:HTMLElement):boolean { - return !this.isOpen(dropdown); - } - - private openDropdown(dropdown:HTMLElement):void { - this.closeOtherItems(dropdown); - this.slideAndFocus(dropdown, () => { - this.active$.next(dropdown); - }); - } - - private closeDropdown(dropdown:HTMLElement, immediate?:boolean):void { - this.slideUp(dropdown, !!immediate); - this.active$.next(null); - } - - private closeOtherItems(dropdown:HTMLElement):void { - this - .openDropdowns() - .forEach((other) => { - if (other !== dropdown) { - this.closeDropdown(other, true); - } - }); - } - - private slideAndFocus(dropdown:HTMLElement, callback:() => void):void { - this.slideDown(dropdown, callback); - setTimeout(() => this.focusFirstInputOrLink(dropdown), ANIMATION_RATE_MS); - } - - private slideDown(dropdown:HTMLElement, callback:() => void):void { - const toDrop = this.getDropdownContainer(dropdown); - toDrop.setAttribute('aria-expanded', 'true'); - dropdown.classList.add('op-app-menu--item_dropdown-open'); - - jQuery(toDrop) - .slideDown(ANIMATION_RATE_MS, callback) - .attr('aria-expanded', 'true'); - } - - private slideUp(dropdown:HTMLElement, immediate:boolean):void { - const toDrop = this.getDropdownContainer(dropdown); - toDrop.removeAttribute('aria-expanded'); - dropdown.classList.remove('op-app-menu--item_dropdown-open'); - - if (immediate) { - toDrop.style.display = 'none'; - } else { - jQuery(toDrop).slideUp(ANIMATION_RATE_MS); - } - } - - // If there is ANY input, it will have precedence over links, - // i.e. links will only get focused, if there is NO input whatsoever - private focusFirstInputOrLink(dropdown:HTMLElement):void { - const toDrop = this.getDropdownContainer(dropdown); - const focusable = findAllFocusableElementsWithin(toDrop); - const toFocus = focusable[0] as HTMLElement; - if (!toFocus) { - return; - } - // actually a simple focus should be enough. - // The rest is only there to work around a rendering bug in webkit (as of Oct 2011), - // occurring mostly inside the login/signup dropdown. - toFocus.blur(); - setTimeout(() => { - toFocus.focus(); - }, 10); - } - - private getDropdownContainer(dropdown:HTMLElement):HTMLElement { - return dropdown.querySelector('.op-app-menu--dropdown') as HTMLElement; - } } diff --git a/frontend/src/app/features/bim/revit_add_in/revit-add-in-settings-button.service.ts b/frontend/src/app/features/bim/revit_add_in/revit-add-in-settings-button.service.ts index 18615e87686..4acb514b457 100644 --- a/frontend/src/app/features/bim/revit_add_in/revit-add-in-settings-button.service.ts +++ b/frontend/src/app/features/bim/revit_add_in/revit-add-in-settings-button.service.ts @@ -55,7 +55,7 @@ export class RevitAddInSettingsButtonService { } public addUserMenuItem():void { - const userMenu = document.getElementById('user-menu'); + const userMenu = document.getElementById('op-app-header--user-menu'); if (userMenu) { const menuItem:HTMLElement = document.createElement('li'); diff --git a/frontend/src/global_styles/common/header/app-menu.sass b/frontend/src/global_styles/common/header/app-menu.sass index 46a71c06dd0..a968333f506 100644 --- a/frontend/src/global_styles/common/header/app-menu.sass +++ b/frontend/src/global_styles/common/header/app-menu.sass @@ -1,89 +1,9 @@ .op-app-menu - list-style: none display: flex margin: 0 - height: 100% - min-width: 0px &--item display: flex - position: relative - height: 100% - min-width: 0px - &--item-dropdown-indicator - display: inline-flex - justify-content: center - align-items: center - - &--item-title - min-width: 0 - white-space: nowrap - - &--item-action - background: transparent - border-right: 1px solid transparent - border-left: 1px solid transparent - border-top: 0 - border-bottom: var(--header-border-bottom-width) solid transparent - border-radius: 0 - display: flex - justify-content: center - align-items: center - height: var(--header-height) - zoom: 1 - color: var(--header-item-font-color) - font-size: var(--header-item-font-size) - text-decoration: none - min-width: 0px - padding: 0 var(--main-menu-x-spacing) - - @media screen and (max-width: $breakpoint-sm) - padding: 0 8px - - .op-app-menu--item_dropdown-open &, &:hover text-decoration: none - background: var(--header-item-bg-hover-color) - color: var(--header-item-font-hover-color) - border-bottom: var(--header-border-bottom-width) solid var(--header-border-bottom-color) !important - border-left-color: var(--main-menu-hover-border-color) - border-right-color: var(--main-menu-hover-border-color) - - &:focus - color: var(--header-item-font-hover-color) - - &--dropdown - position: absolute - top: 100% - left: auto - right: 0 - border-radius: 0 - box-shadow: var(--shadow-floating-small) - overflow: visible - padding: 3px 0 - margin: 0 - - min-width: 270px - padding: 6px 0 - border-top: 0 - background-color: var(--body-background) - max-height: calc(100vh - var(--header-height)) - overflow-y: auto - overflow-x: hidden - scrollbar-color: transparent transparent - scrollbar-width: thin - - &_open - display: flex - - @at-root .op-app-menu_drop-left & - left: 0 - right: auto - - @media screen and (max-width: $breakpoint-sm) - position: fixed - top: var(--header-height) - left: 0 - right: 0 - width: 100vw diff --git a/lib/redmine/menu_manager/menu_helper.rb b/lib/redmine/menu_manager/menu_helper.rb index adc1ac9f675..0172605d746 100644 --- a/lib/redmine/menu_manager/menu_helper.rb +++ b/lib/redmine/menu_manager/menu_helper.rb @@ -79,55 +79,6 @@ module Redmine::MenuManager::MenuHelper items.select { |item| item.children.empty? } end - ## - # Render a dropdown menu item with the given MenuItem children. - # Caller may add additional items through the optional block. - # Remaining options are passed through to +render_menu_dropdown+. - def render_menu_dropdown_with_items(label:, label_options:, items:, options: {}, project: nil) - selected = any_item_selected?(items) - label_node = render_drop_down_label_node(label, selected, label_options) - - options[:drop_down_class] = "op-menu #{options.fetch(:drop_down_class, '')}" - render_menu_dropdown(label_node, options) do - items.each do |item| - concat render_menu_node(item, project) - end - - concat(yield) if block_given? - end - end - - ## - # Render a dropdown menu item with arbitrary content. - # As these are not menu-items, the whole dropdown may never be marked selected. - # Available options: - # menu_item_class: Additional classes for the menu item li wrapper - # drop_down_class: Additional classes for the hidden drop down - def render_menu_dropdown(label_node, options = {}, &) - content_tag :li, class: "op-app-menu--item op-app-menu--item_has-dropdown #{options[:menu_item_class]}" do - concat(label_node) - concat(content_tag(:ul, - style: "display:none", - id: options[:drop_down_id], - class: "op-app-menu--dropdown #{options.fetch(:drop_down_class, '')}", - &)) - end - end - - def render_drop_down_label_node(label, selected, options = {}) - options[:title] ||= selected ? t(:description_current_position) + label : label - options[:aria] = { haspopup: "true" } - options[:class] = "op-app-menu--item-action #{options[:class]} #{selected ? 'selected' : ''}" - options[:span_class] = "op-app-menu--item-title #{options[:span_class]}" - - link_to("#", options) do - concat(op_icon(options[:icon])) if options[:icon] - concat(you_are_here_info(selected).html_safe) - concat(content_tag(:span, label, class: options[:span_class])) - concat(''.html_safe) unless options.key?(:icon) - end - end - def render_menu_node(node, project = nil) return "" unless allowed_node?(node, User.current, project) diff --git a/lib/redmine/menu_manager/top_menu_helper.rb b/lib/redmine/menu_manager/top_menu_helper.rb index 871b9f20287..cd622d49ba4 100644 --- a/lib/redmine/menu_manager/top_menu_helper.rb +++ b/lib/redmine/menu_manager/top_menu_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -110,45 +112,61 @@ module Redmine::MenuManager::TopMenuHelper end def render_login_drop_down - url = { controller: "/account", action: "login" } - link = link_to url, - class: "op-app-menu--item-action", - title: I18n.t(:label_login) do - concat content_tag(:span, I18n.t(:label_login), class: "op-app-menu--item-title hidden-for-mobile") - concat content_tag(:i, "", class: "op-app-menu--item-dropdown-indicator button--dropdown-indicator hidden-for-mobile") - concat content_tag(:i, "", class: "icon2 icon-user hidden-for-desktop") + # url = { controller: "/account", action: "login" } + # link = link_to url, + # class: "op-app-menu--item-action", + # title: I18n.t(:label_login) do + # concat content_tag(:span, I18n.t(:label_login), class: "op-app-menu--item-title hidden-for-mobile") + # concat content_tag(:i, "", class: "op-app-menu--item-dropdown-indicator button--dropdown-indicator hidden-for-mobile") + # concat content_tag(:i, "", class: "icon2 icon-user hidden-for-desktop") + # end + + render Primer::Alpha::ActionMenu.new(classes: "op-app-menu--item", + menu_id: "op-app-header--login", + anchor_align: :end) do |menu| + menu.with_show_button(scheme: :invisible, + classes: "op-app-header--primer-button op-top-menu-user", + "aria-label": I18n.t(:label_login)) do + I18n.t(:label_login) + end end - render_menu_dropdown(link, menu_item_class: "") do - render_login_partial - end + # render_menu_dropdown(link, menu_item_class: "") do + # render_login_partial + # end end def render_direct_login - link = link_to signin_path, - class: "op-app-menu--item-action login", - title: I18n.t(:label_login) do - concat content_tag(:span, I18n.t(:label_login), class: "op-app-menu--item-title hidden-for-mobile") - concat content_tag(:i, "", class: "icon2 icon-user hidden-for-desktop") - end - - content_tag :li, class: "" do - concat link - end + render(Primer::Beta::IconButton.new(icon: "person", + tag: :a, + scheme: :invisible, + href: signin_path, + aria: { label: I18n.t(:label_login) })) end def render_user_drop_down(items) avatar = avatar(User.current, class: "op-top-menu-user-avatar", hover_card: { active: false }) - render_menu_dropdown_with_items( - label: avatar.presence || "", - label_options: { - title: User.current.name, - class: "op-top-menu-user", - icon: (avatar.present? ? "overridden-by-avatar" : "icon-user") - }, - items:, - options: { drop_down_id: "user-menu", menu_item_class: "last-child" } - ) + + render Primer::Alpha::ActionMenu.new(classes: "op-app-menu--item", + menu_id: "op-app-header--user-menu", + anchor_align: :end) do |menu| + menu.with_show_button(scheme: :invisible, + classes: "op-app-header--primer-button op-top-menu-user", + test_selector: "op-app-header--modules-menu-button", + "aria-label": I18n.t("label_user_menu")) do + avatar.presence || render(Primer::Beta::Octicon.new(icon: :person, aria: { label: I18n.t("label_user_menu") })) + end + + items.each do |item| + menu.with_item( + href: allowed_node_url(item, nil), + label: item.caption, + test_selector: "op-menu--item-action" + ) do |menu_item| + menu_item.with_leading_visual_icon(icon: item.icon) if item.icon + end + end + end end def render_login_partial diff --git a/spec/support/components/menu/dropdown.rb b/spec/support/components/menu/dropdown.rb index d3250397a5b..fd9bb79185c 100644 --- a/spec/support/components/menu/dropdown.rb +++ b/spec/support/components/menu/dropdown.rb @@ -34,20 +34,24 @@ module Components include Capybara::RSpecMatchers include RSpec::Matchers + def id + raise NotImplementedError, "Please provide an ID" + end + def toggle trigger_element.click end def expect_closed - expect(page).to have_no_css(".op-app-menu--dropdown") + expect(page).to have_no_css("##{id}-list") end def expect_open - expect(page).to have_css(".op-app-menu--dropdown") + expect(page).to have_css("##{id}-list") end def within_dropdown(&) - page.within(".op-app-menu--dropdown", &) + page.within("##{id}-list", &) end def trigger_element diff --git a/spec/support/components/menu/quick_add_menu.rb b/spec/support/components/menu/quick_add_menu.rb index 6f746e9b05f..40ce1c5b1d1 100644 --- a/spec/support/components/menu/quick_add_menu.rb +++ b/spec/support/components/menu/quick_add_menu.rb @@ -31,6 +31,10 @@ require_relative "dropdown" module Components class QuickAddMenu < Dropdown + def id + "op-app-header--quick-add-menu" + end + def expect_visible expect(trigger_element).to be_present end