mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Primerize the top bar user dropdown
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user