Primerize the top bar user dropdown

This commit is contained in:
Henriette Darge
2025-07-21 15:35:50 +02:00
parent 375758b32a
commit 6d498ccf20
9 changed files with 65 additions and 345 deletions
-1
View File
@@ -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;
}
+1
View File
@@ -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"
@@ -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<HTMLElement|null>(null);
constructor(@Inject(DOCUMENT) private document:Document) {
}
register():void {
this.menuContainer = this.document.querySelector<HTMLElement>('.op-app-header') as HTMLElement;
this.setupDropdownClick();
this.closeOnBodyClick();
this.accessibility();
this.skipContentClickListener();
}
public activeDropdown$():Observable<HTMLElement|null> {
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<HTMLElement>('.op-app-menu--item_dropdown-open');
return elements ? Array.from(elements) : [];
}
private dropdowns():HTMLElement[] {
const elements = this.menuContainer?.querySelectorAll<HTMLElement>('.op-app-menu--item_has-dropdown');
return elements ? Array.from(elements) : [];
}
private setupDropdownClick():void {
this.dropdowns().forEach((el) => {
const action = el.querySelector<HTMLElement>('.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;
}
}
@@ -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');
@@ -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
-49
View File
@@ -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('<i class="op-app-menu--item-dropdown-indicator button--dropdown-indicator"></i>'.html_safe) unless options.key?(:icon)
end
end
def render_menu_node(node, project = nil)
return "" unless allowed_node?(node, User.current, project)
+48 -30
View File
@@ -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
+7 -3
View File
@@ -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
@@ -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