[DURACOM-195] Header, navbar, and sidebar refactoring

This commit is contained in:
Davide Negretti
2023-11-23 19:03:03 +01:00
parent 7d2fdb7598
commit 8718bd0df6
65 changed files with 913 additions and 702 deletions

View File

@@ -6,7 +6,7 @@ describe('Collection Statistics Page', () => {
it('should load if you click on "Statistics" from a Collection page', () => {
cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
});

View File

@@ -6,7 +6,7 @@ describe('Community Statistics Page', () => {
it('should load if you click on "Statistics" from a Community page', () => {
cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
});

View File

@@ -5,7 +5,7 @@ import '../support/commands';
describe('Site Statistics Page', () => {
it('should load if you click on "Statistics" from homepage', () => {
cy.visit('/');
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', '/statistics');
});

View File

@@ -6,7 +6,7 @@ describe('Item Statistics Page', () => {
it('should load if you click on "Statistics" from an Item/Entity page', () => {
cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
});

View File

@@ -3,31 +3,31 @@ import { testA11y } from 'cypress/support/utils';
const page = {
openLoginMenu() {
// Click the "Log In" dropdown menu in header
cy.get('ds-themed-navbar [data-test="login-menu"]').click();
cy.get('ds-themed-header [data-test="login-menu"]').click();
},
openUserMenu() {
// Once logged in, click the User menu in header
cy.get('ds-themed-navbar [data-test="user-menu"]').click();
cy.get('ds-themed-header [data-test="user-menu"]').click();
},
submitLoginAndPasswordByPressingButton(email, password) {
// Enter email
cy.get('ds-themed-navbar [data-test="email"]').type(email);
cy.get('ds-themed-header [data-test="email"]').type(email);
// Enter password
cy.get('ds-themed-navbar [data-test="password"]').type(password);
cy.get('ds-themed-header [data-test="password"]').type(password);
// Click login button
cy.get('ds-themed-navbar [data-test="login-button"]').click();
cy.get('ds-themed-header [data-test="login-button"]').click();
},
submitLoginAndPasswordByPressingEnter(email, password) {
// In opened Login modal, fill out email & password, then click Enter
cy.get('ds-themed-navbar [data-test="email"]').type(email);
cy.get('ds-themed-navbar [data-test="password"]').type(password);
cy.get('ds-themed-navbar [data-test="password"]').type('{enter}');
cy.get('ds-themed-header [data-test="email"]').type(email);
cy.get('ds-themed-header [data-test="password"]').type(password);
cy.get('ds-themed-header [data-test="password"]').type('{enter}');
},
submitLogoutByPressingButton() {
// This is the POST command that will actually log us out
cy.intercept('POST', '/server/api/authn/logout').as('logout');
// Click logout button
cy.get('ds-themed-navbar [data-test="logout-button"]').click();
cy.get('ds-themed-header [data-test="logout-button"]').click();
// Wait until above POST command responds before continuing
// (This ensures next action waits until logout completes)
cy.wait('@logout');
@@ -102,10 +102,10 @@ describe('Login Modal', () => {
page.openLoginMenu();
// Registration link should be visible
cy.get('ds-themed-navbar [data-test="register"]').should('be.visible');
cy.get('ds-themed-header [data-test="register"]').should('be.visible');
// Click registration link & you should go to registration page
cy.get('ds-themed-navbar [data-test="register"]').click();
cy.get('ds-themed-header [data-test="register"]').click();
cy.location('pathname').should('eq', '/register');
cy.get('ds-register-email').should('exist');
@@ -119,10 +119,10 @@ describe('Login Modal', () => {
page.openLoginMenu();
// Forgot password link should be visible
cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible');
cy.get('ds-themed-header [data-test="forgot"]').should('be.visible');
// Click link & you should go to Forgot Password page
cy.get('ds-themed-navbar [data-test="forgot"]').click();
cy.get('ds-themed-header [data-test="forgot"]').click();
cy.location('pathname').should('eq', '/forgot');
cy.get('ds-forgot-email').should('exist');

View File

@@ -1,15 +1,15 @@
const page = {
fillOutQueryInNavBar(query) {
// Click the magnifying glass
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
cy.get('ds-themed-header [data-test="header-search-icon"]').click();
// Fill out a query in input that appears
cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query);
cy.get('ds-themed-header [data-test="header-search-box"]').type(query);
},
submitQueryByPressingEnter() {
cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}');
cy.get('ds-themed-header [data-test="header-search-box"]').type('{enter}');
},
submitQueryByPressingIcon() {
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
cy.get('ds-themed-header [data-test="header-search-icon"]').click();
}
};

View File

@@ -1,23 +1,21 @@
<div class="sidebar-section">
<a class="nav-item nav-link d-flex flex-row flex-nowrap"
<li role="presentation">
<a class="sidebar-section-wrapper"
[ngClass]="{ disabled: isDisabled }"
role="menuitem"
[attr.aria-disabled]="isDisabled"
[attr.aria-labelledby]="'sidebarName-' + section.id"
[title]="('menu.section.icon.' + section.id) | translate"
[attr.aria-labelledby]="adminMenuSectionTitleId(section.id)"
[routerLink]="itemModel.link"
(keyup.space)="navigate($event)"
(keyup.enter)="navigate($event)"
href="javascript:void(0);"
>
<div class="shortcut-icon">
<div class="sidebar-fixed-element-wrapper" data-test="sidebar-section-icon" aria-hidden="true">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</div>
<div class="sidebar-collapsible">
<div class="toggle">
<span id="sidebarName-{{section.id}}" class="section-header-text">
{{itemModel.text | translate}}
</span>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item">
<span [id]="adminMenuSectionTitleId(section.id)">{{itemModel.text | translate}}</span>
</div>
</div>
</a>
</div>
</li>

View File

@@ -48,7 +48,7 @@ describe('AdminSidebarSectionComponent', () => {
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
const icon = fixture.debugElement.query(By.css('[data-test="sidebar-section-icon"]')).query(By.css('i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
it('should not contain the disabled class', () => {
@@ -88,7 +88,7 @@ describe('AdminSidebarSectionComponent', () => {
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
const icon = fixture.debugElement.query(By.css('[data-test="sidebar-section-icon"]')).query(By.css('i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
it('should contain the disabled class', () => {

View File

@@ -52,4 +52,12 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
this.router.navigate(this.itemModel.link);
}
}
adminMenuSectionId(sectionId: string) {
return `admin-menu-section-${sectionId}`;
}
adminMenuSectionTitleId(sectionId: string) {
return `admin-menu-section-${sectionId}-title`;
}
}

View File

@@ -1,54 +1,62 @@
<nav class="navbar navbar-dark p-0"
[ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}"
<nav class="navbar navbar-dark p-0 vh-100"
id="admin-sidebar"
[attr.aria-label]="'menu.header.nav.description' | translate"
[ngClass]="{'expanded': sidebarOpen, 'collapsed': sidebarClosed, 'transitioning': sidebarTransitioning}"
[@slideSidebar]="{
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),
params: {sidebarWidth: (sidebarWidth | async)}
params: { collapsedWidth: (collapsedSidebarWidth$ | async), expandedWidth: (expandedSidebarWidth$ | async) }
}" (@slideSidebar.done)="finishSlide($event)" (@slideSidebar.start)="startSlide($event)"
*ngIf="menuVisible | async"
(mouseenter)="handleMouseEnter($event)"
(mouseleave)="handleMouseLeave($event)"
role="navigation" [attr.aria-label]="'menu.header.admin.description' |translate">
<div class="sidebar-top-level-items">
<ul class="navbar-nav">
<li class="admin-menu-header">
<div class="sidebar-section">
<div href="javascript:void(0);" class="nav-item d-flex flex-row flex-nowrap py-0">
<div class="shortcut-icon navbar-brand logo-wrapper">
<img class="admin-logo" src="assets/images/dspace-logo-mini.svg"
[alt]="('menu.header.image.logo') | translate">
</div>
<div class="sidebar-collapsible navbar-brand">
<div class="mr-0">
<h4 class="section-header-text mb-0">{{ 'menu.header.admin' | translate }}</h4>
</div>
</div>
</div>
</div>
</li>
(mouseleave)="handleMouseLeave($event)">
<li *ngFor="let section of (sections | async)">
<!-- HEADER -->
<div class="sidebar-full-width-container" id="sidebar-header-container" aria-hidden="true">
<div class="sidebar-section-wrapper">
<div class="sidebar-fixed-element-wrapper">
<img id="admin-sidebar-logo" src="assets/images/dspace-logo-mini.svg" [alt]="('menu.header.image.logo') | translate" aria-hidden="true">
</div>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item">
<h4 class="my-1">{{ 'menu.header.admin' | translate }}</h4>
</div>
</div>
</div>
</div>
<!-- ITEMS -->
<div class="sidebar-full-width-container" id="sidebar-top-level-items-container">
<ul class="sidebar-full-width-container" id="sidebar-top-level-items" role="menubar"
[attr.aria-label]="'menu.header.admin.description' |translate">
<ng-container *ngFor="let section of (sections | async)">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</li>
</ng-container>
</ul>
</div>
<div class="navbar-nav">
<div class="sidebar-section" id="sidebar-collapse-toggle">
<button class="nav-item nav-link sidebar-section d-flex flex-row flex-nowrap border-0" type="button"
<!-- TOGGLER -->
<div class="sidebar-full-width-container" id="sidebar-collapse-toggle-container" aria-hidden="true">
<a class="sidebar-section-wrapper sidebar-full-width-container"
href="javascript:void(0);"
(click)="toggle($event)"
(keyup.space)="toggle($event)"
>
<span class="shortcut-icon">
<div class="sidebar-fixed-element-wrapper">
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
[title]="'menu.section.icon.pin' | translate"></i>
<i *ngIf="!(menuCollapsed | async)" class="fas fa-fw fa-angle-double-left"
[title]="'menu.section.icon.unpin' | translate"></i>
</span>
<span class="sidebar-collapsible text-left">
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>
</span>
</button>
</div>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item">
{{ ((menuCollapsed | async) ? 'menu.section.pin' : 'menu.section.unpin' ) | translate }}
</div>
</div>
</a>
</div>
</nav>

View File

@@ -1,123 +1,140 @@
:host {
--ds-icon-z-index: 10;
/* SIDEBAR SIZE AND POSITION */
/* Sidebar hierarchy:
§ nav
§ .sidebar-full-width-container (any OPTIONAL full width element with no horizontal margin or padding - it can be nested)
§ .sidebar-section-wrapper
§ .sidebar-fixed-element-wrapper
§ .sidebar-collapsible-element-outer-wrapper
§ .sidebar-collapsible-element-inner-wrapper
§ .sidebar-item
*/
// Sidebar position
position: fixed;
left: 0;
top: 0;
height: 100vh;
flex: 1 1 auto;
nav {
background-color: var(--ds-admin-sidebar-bg);
height: 100%;
z-index: var(--ds-sidebar-z-index);
// Sidebar size and content position
nav#admin-sidebar {
max-width: var(--ds-admin-sidebar-fixed-element-width); // Sidebar collapsed width
display: flex;
flex-direction: column;
> div {
width: 100%;
&.sidebar-top-level-items {
flex: 1;
overflow: auto;
flex-wrap: nowrap;
div#sidebar-top-level-items-container {
flex: 1 1 auto; // Fill available vertical space
overflow-x: hidden;
overflow-y: auto;
@include dark-scrollbar;
}
}
&.inactive ::ng-deep .sidebar-collapsible {
margin-left: calc(-1 * var(--ds-sidebar-items-width));
img#admin-sidebar-logo {
width: 20px;
}
.navbar-nav {
.admin-menu-header {
background-color: var(--ds-admin-sidebar-header-bg);
.sidebar-section {
background-color: inherit;
}
.logo-wrapper {
img {
height: 20px;
}
}
.section-header-text {
line-height: 1.5;
}
.navbar-brand {
margin-right: 0;
}
}
}
::ng-deep {
.navbar-nav {
.sidebar-section {
display: flex;
align-content: stretch;
background-color: var(--ds-admin-sidebar-bg);
overflow-x: visible;
.nav-item {
padding-top: var(--bs-spacer);
padding-bottom: var(--bs-spacer);
background-color: inherit;
&:focus-visible {
// since links fill the whole sidebar, we _inset_ the outline
outline-offset: -4px;
// replace padding with margins so it doesn't extend over the :focus-visible outline
// → can't remove the padding altogether; the icon needs to fill out
// the collapsed width of the sidebar for the slide animation to look decent.
.shortcut-icon {
padding-left: 0;
padding-right: 0;
margin-left: var(--ds-icon-padding);
margin-right: var(--ds-icon-padding);
}
.logo-wrapper {
margin-right: var(--bs-navbar-padding-x) !important;
}
.navbar-brand {
padding-top: 0;
padding-bottom: 0;
margin-top: var(--bs-navbar-brand-padding-y);
margin-bottom: var(--bs-navbar-brand-padding-y);
}
}
}
.shortcut-icon {
background-color: inherit;
padding-left: var(--ds-icon-padding);
padding-right: var(--ds-icon-padding);
z-index: var(--ds-icon-z-index);
align-self: baseline;
}
.sidebar-collapsible {
padding-left: 0;
padding-right: var(--bs-spacer);
width: var(--ds-sidebar-items-width);
position: relative;
.toggle {
// This class must be applied to any nested wrapper containing a sidebar section
.sidebar-full-width-container {
width: 100%;
padding-left: 0 !important;
padding-right: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
ul {
padding-top: var(--bs-spacer);
li a {
padding-left: var(--bs-spacer);
}
}
// This class must be applied to the innermost block element containing a section or subsection link
// (it can be applied together with `sidebar-collapsible-element-inner-wrapper`)
.sidebar-item {
padding-top: 1rem;
padding-bottom: 1rem;
}
&.active > .sidebar-collapsible > .nav-link {
color: var(--bs-navbar-dark-active-color);
// These classes handle the collapsing behavior
.sidebar-section-wrapper {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
// These elements have fixed width and determine the width of the collapsed sidebar
& > .sidebar-fixed-element-wrapper {
min-width: var(--ds-admin-sidebar-fixed-element-width);
flex: 1 1 auto; // Fill available space
// Align the icons
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
& > .sidebar-collapsible-element-outer-wrapper {
display: flex;
flex-wrap: wrap;
justify-content: flex-end; // make inner wrapper slide on the left when collapsing
max-width: calc(100% - var(--ds-admin-sidebar-fixed-element-width)); // fill available space
padding-left: var(--ds-dark-scrollbar-width); // leave room for scrollbar
overflow-x: hidden; // hide inner wrapper when sidebar is collapsed
// These elements have fixed width and slide on the left when the sidebar is collapsed
// Their content should fill all available space
& > .sidebar-collapsible-element-inner-wrapper {
min-width: calc(var(--ds-admin-sidebar-collapsible-element-width) - var(--ds-dark-scrollbar-width));
height: 100%;
padding-right: 16px !important;
}
}
}
}
// Set here any style that depends on the sidebar status
&.collapsed, &.transitioning, &.expanded { }
}
/* SIDEBAR STYLE */
nav#admin-sidebar {
background-color: var(--ds-admin-sidebar-bg);
::ng-deep {
color: white;
// Set here the style of the *-menu-item nested components
.ds-menu-item {
}
a {
color: var(--ds-admin-sidebar-link-color);
text-decoration: none;
&:hover, &:focus {
color: var(--ds-admin-sidebar-link-hover-color);
}
}
}
div#sidebar-header-container {
background-color: var(--ds-admin-sidebar-header-bg);
.sidebar-fixed-element-wrapper {
background-color: var(--ds-admin-sidebar-header-bg);
}
}
div#sidebar-collapse-toggle-container {
.sidebar-collapsible-element-inner-wrapper {
}
}
}
}

View File

@@ -143,7 +143,7 @@ describe('AdminSidebarComponent', () => {
describe('when the collapse link is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle > button'));
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle-container > a'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}

View File

@@ -1,4 +1,4 @@
import { Component, HostListener, Injector, OnInit } from '@angular/core';
import { Component, HostListener, Injector, Input, OnInit } from '@angular/core';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
@@ -28,9 +28,14 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
menuID = MenuID.ADMIN;
/**
* Observable that emits the width of the collapsible menu sections
* Observable that emits the width of the sidebar when expanded
*/
sidebarWidth: Observable<string>;
@Input() expandedSidebarWidth$: Observable<string>;
/**
* Observable that emits the width of the sidebar when collapsed
*/
@Input() collapsedSidebarWidth$: Observable<string>;
/**
* Is true when the sidebar is open, is false when the sidebar is animating or closed
@@ -44,6 +49,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
*/
sidebarClosed = !this.sidebarOpen; // Closed in UI, animation finished
/**
* Is true when the sidebar is opening or closing
* @type {boolean}
*/
sidebarTransitioning = !this.sidebarOpen; // Animation in progress
/**
* Emits true when either the menu OR the menu's preview is expanded, else emits false
*/
@@ -69,7 +80,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
*/
ngOnInit(): void {
super.ngOnInit();
this.sidebarWidth = this.variableService.getVariable('--ds-sidebar-items-width');
this.authService.isAuthenticated()
.subscribe((loggedIn: boolean) => {
if (loggedIn) {
@@ -134,6 +144,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
* @param event The animation event
*/
startSlide(event: any): void {
this.sidebarTransitioning = true;
if (event.toState === 'expanded') {
this.sidebarClosed = false;
} else if (event.toState === 'collapsed') {
@@ -146,6 +157,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
* @param event The animation event
*/
finishSlide(event: any): void {
this.sidebarTransitioning = false;
if (event.fromState === 'expanded') {
this.sidebarClosed = true;
} else if (event.fromState === 'collapsed') {

View File

@@ -1,37 +1,47 @@
<div class="sidebar-section" [ngClass]="{'expanded': (expanded | async)}"
<li [ngClass]="{'expanded': (isExpanded$ | async)}" role="presentation"
[@bgColor]="{
value: ((expanded | async) ? 'endBackground' : 'startBackground'),
params: {endColor: (sidebarActiveBg | async)}}">
<div class="nav-item nav-link d-flex flex-row flex-nowrap"
role="button" tabindex="0"
[attr.aria-labelledby]="'sidebarName-' + section.id"
[attr.aria-expanded]="expanded | async"
[title]="('menu.section.icon.' + section.id) | translate"
value: ((isExpanded$ | async) ? 'endBackground' : 'startBackground'),
params: {endColor: (sidebarActiveBg$ | async)}
}">
<a class="sidebar-section-wrapper"
role="menuitem" tabindex="0"
aria-haspopup="menu"
[attr.aria-controls]="adminMenuSectionId(section.id)"
[attr.aria-expanded]="isExpanded$ | async"
[attr.aria-label]="('menu.section.toggle.' + section.id) | translate"
[class.disabled]="section.model?.disabled"
(click)="toggleSection($event)"
(keyup.space)="toggleSection($event)"
(keyup.enter)="toggleSection($event)"
href="javascript:void(0);"
>
<div class="shortcut-icon h-100">
<div class="sidebar-fixed-element-wrapper" data-test="sidebar-section-icon" aria-hidden="true">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</div>
<div class="sidebar-collapsible">
<div class="toggle">
<span id="sidebarName-{{section.id}}" class="section-header-text">
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item toggler-wrapper">
<span [id]="adminMenuSectionTitleId(section.id)">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span>
<i class="fas fa-chevron-right fa-pull-right"
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'"
[title]="('menu.section.toggle.' + section.id) | translate"
<i class="fas fa-chevron-right fa-xs" aria-hidden="true"
[@rotate]="(isExpanded$ | async) ? 'expanded' : 'collapsed'"
></i>
</div>
<ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)">
<li *ngFor="let subSection of (subSections$ | async)">
</div>
</a>
<div class="sidebar-section-wrapper subsection" @slide *ngIf="(isExpanded$ | async)">
<div class="sidebar-fixed-element-wrapper"></div>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper">
<ul class="sidebar-sub-level-item-list" role="menu" [id]="adminMenuSectionId(section.id)" [attr.aria-label]="('menu.section.' + section.id) | translate">
<li class="sidebar-item" role="presentation" *ngFor="let subSection of (subSections$ | async)">
{{ (sectionMap$ | async).get(subSection.id).component | json}}
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</li>
</ul>
</div>
</div>
</div>
</div>
</li>

View File

@@ -1,23 +1,24 @@
:host ::ng-deep {
.fa-chevron-right {
padding-left: calc(var(--bs-spacer) / 2);
font-size: 0.5rem;
line-height: 3;
:host {
ul.sidebar-sub-level-item-list {
list-style: none;
margin: 0;
padding: 0;
::ng-deep .ds-menu-item {
margin-left: 12px;
}
}
.sidebar-sub-level-items {
list-style: disc;
color: var(--bs-navbar-dark-color);
overflow: hidden;
margin-bottom: calc(-1 * var(--bs-spacer)); // the bottom-most nav-item is padded, no need for double spacing
}
.sidebar-collapsible {
.toggler-wrapper {
display: flex;
flex-direction: column;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
}
li.sidebar-section.expanded {
background-color: var(--ds-admin-sidebar-active-bg) !important;
.subsection {
.sidebar-collapsible-element-inner-wrapper {
overflow-y: hidden;
}
}
}

View File

@@ -49,14 +49,14 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.shortcut-icon > i.fas'));
const icon = fixture.debugElement.query(By.css('[data-test="sidebar-section-icon"] > i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
describe('when the header text is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section > div.nav-item'));
const sidebarToggler = fixture.debugElement.query(By.css('a.sidebar-section-wrapper'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}

View File

@@ -31,23 +31,23 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
/**
* The background color of the section when it's active
*/
sidebarActiveBg;
sidebarActiveBg$: Observable<string>;
/**
* Emits true when the sidebar is currently collapsed, true when it's expanded
*/
sidebarCollapsed: Observable<boolean>;
isSidebarCollapsed$: Observable<boolean>;
/**
* Emits true when the sidebar's preview is currently collapsed, true when it's expanded
*/
sidebarPreviewCollapsed: Observable<boolean>;
isSidebarPreviewCollapsed$: Observable<boolean>;
/**
* Emits true when the menu section is expanded, else emits false
* This is true when the section is active AND either the sidebar or it's preview is open
*/
expanded: Observable<boolean>;
isExpanded$: Observable<boolean>;
constructor(
@Inject('sectionDataProvider') menuSection,
@@ -64,12 +64,20 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
*/
ngOnInit(): void {
super.ngOnInit();
this.sidebarActiveBg = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
this.sidebarCollapsed = this.menuService.isMenuCollapsed(this.menuID);
this.sidebarPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID);
this.expanded = combineLatestObservable(this.active, this.sidebarCollapsed, this.sidebarPreviewCollapsed)
.pipe(
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID);
this.isExpanded$ = combineLatestObservable([this.active, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe(
map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed)))
);
}
toggleSection($event: Event) {
this.menuService.expandMenuPreview(this.menuID); // fixes accessibility issue
super.toggleSection($event);
}
adminMenuSubsectionId(sectionId: string, subsectionId: string) {
return `admin-menu-section-${sectionId}-${subsectionId}`;
}
}

View File

@@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import { Component, Input } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { AdminSidebarComponent } from './admin-sidebar.component';
import { Observable } from 'rxjs';
/**
* Themed wrapper for AdminSidebarComponent
@@ -11,6 +12,19 @@ import { AdminSidebarComponent } from './admin-sidebar.component';
templateUrl: '../../shared/theme-support/themed.component.html',
})
export class ThemedAdminSidebarComponent extends ThemedComponent<AdminSidebarComponent> {
/**
* Observable that emits the width of the sidebar when expanded
*/
@Input() expandedSidebarWidth$: Observable<string>;
/**
* Observable that emits the width of the sidebar when collapsed
*/
@Input() collapsedSidebarWidth$: Observable<string>;
protected inAndOutputNames: (keyof AdminSidebarComponent & keyof this)[] = ['collapsedSidebarWidth$', 'expandedSidebarWidth$'];
protected getComponentName(): string {
return 'AdminSidebarComponent';
}

View File

@@ -1,22 +1,36 @@
<div class="nav-item dropdown expandable-navbar-section text-md-center"
*ngVar="(active | async) as isActive"
(keyup.enter)="isActive ? deactivateSection($event) : activateSection($event)"
(keyup.space)="isActive ? deactivateSection($event) : activateSection($event)"
(keydown.space)="$event.preventDefault()"
(mouseenter)="activateSection($event)"
(mouseleave)="deactivateSection($event)">
<button class="btn btn-link nav-link dropdown-toggle" routerLinkActive="active" type="button"
[class.disabled]="section.model?.disabled"
<li class="ds-menu-item-wrapper text-md-center" role="presentation"
[id]="'expandable-navbar-section-' + section.id"
(mouseenter)="onMouseEnter($event, isActive)"
(mouseleave)="onMouseLeave($event, isActive)"
data-test="navbar-section-wrapper"
*ngVar="(active | async) as isActive">
<a href="javascript:void(0);" routerLinkActive="active"
role="menuitem"
(keyup.enter)="toggleSection($event)"
(keyup.space)="toggleSection($event)"
(click)="toggleSection($event)"
data-toggle="dropdown">
(keydown.space)="$event.preventDefault()"
aria-haspopup="menu"
data-test="navbar-section-toggler"
[attr.aria-expanded]="isActive"
[attr.aria-controls]="expandableNavbarSectionId(section.id)"
class="d-flex flex-row flex-nowrap align-items-center gapx-1 ds-menu-toggler-wrapper"
[class.disabled]="section.model?.disabled"
id="browseDropdown">
<span class="flex-fill">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</button>
<ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)"
class="m-0 shadow-none border-top-0 dropdown-menu show">
<li *ngFor="let subSection of (subSections$ | async)">
<!-- <span class="sr-only">{{'nav.expandable-navbar-section-suffix' | translate}}</span>-->
</span>
<i class="fas fa-caret-down fa-xs toggle-menu-icon" aria-hidden="true"></i>
</a>
<ul @slide *ngIf="isActive" (click)="deactivateSection($event)"
[id]="expandableNavbarSectionId(section.id)"
role="menu"
class="dropdown-menu show nav-dropdown-menu m-0 shadow-none border-top-0 px-3 px-md-0 pt-0 pt-md-1">
<li *ngFor="let subSection of (subSections$ | async)" class="text-nowrap" role="presentation">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</li>
</ul>
</div>
</li>

View File

@@ -1,47 +1,24 @@
.expandable-navbar-section {
display: flex;
height: 100%;
flex-direction: column;
justify-content: center;
}
.btn.nav-link {
text-align: left;
border: none;
&:active, &:focus {
box-shadow: none !important;
:host {
li.ds-menu-item-wrapper {
position: relative; // align dropdown menu with respect to this element
}
}
.dropdown-menu {
background-color: var(--ds-expandable-navbar-bg);
.dropdown-menu {
overflow: hidden;
min-width: 100%;
@include media-breakpoint-down(sm) {
border: 0;
background-color: var(--ds-expandable-navbar-bg);
}
@include media-breakpoint-up(md) {
border-top-left-radius: 0;
border-top-right-radius: 0;
::ng-deep a.nav-link {
color: var(--ds-expandable-navbar-link-color) !important;
padding-right: var(--bs-spacer);
padding-left: var(--bs-spacer);
white-space: nowrap;
background-color: var(--ds-navbar-dropdown-bg);
}
}
&:hover, &:focus {
color: var(--ds-expandable-navbar-link-color-hover) !important;
.toggle-menu-icon {
&, &:hover {
text-decoration: none;
}
}
}
/** Mobile menu styling **/
@media screen and (max-width: map-get($grid-breakpoints, md)) {
.dropdown-toggle {
&:after {
float: right;
margin-top: calc(var(--bs-spacer) / 2);
}
}
.dropdown-menu {
border: 0;
}
}

View File

@@ -47,120 +47,138 @@ describe('ExpandableNavbarSectionComponent', () => {
expect(component).toBeTruthy();
});
describe('when the mouse enters the section header', () => {
describe('when the mouse enters the section header (while inactive)', () => {
beforeEach(() => {
spyOn(component, 'onMouseEnter').and.callThrough();
spyOn(component, 'activateSection').and.callThrough();
spyOn(menuService, 'activateSection');
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-wrapper"]'));
sidebarToggler.triggerEventHandler('mouseenter', {
preventDefault: () => {/**/
}
});
});
it('should call activateSection on the menuService', () => {
it('should call onMouseEnter', () => {
expect(component.onMouseEnter).toHaveBeenCalled();
});
it('should activate the section', () => {
expect(component.activateSection).toHaveBeenCalled();
expect(menuService.activateSection).toHaveBeenCalled();
});
});
describe('when the mouse leaves the section header', () => {
describe('when the mouse leaves the section header (while active)', () => {
beforeEach(() => {
spyOn(component, 'onMouseLeave').and.callThrough();
spyOn(component, 'deactivateSection').and.callThrough();
spyOn(menuService, 'deactivateSection');
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
// Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true));
component.ngOnInit();
component.mouseEntered = true;
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-wrapper"]'));
sidebarToggler.triggerEventHandler('mouseleave', {
preventDefault: () => {/**/
}
});
});
it('should call deactivateSection on the menuService', () => {
it('should call onMouseLeave', () => {
expect(component.onMouseLeave).toHaveBeenCalled();
});
it('should deactivate the section', () => {
expect(component.deactivateSection).toHaveBeenCalled();
expect(menuService.deactivateSection).toHaveBeenCalled();
});
});
describe('when Enter key is pressed on section header (while inactive)', () => {
beforeEach(() => {
spyOn(menuService, 'activateSection');
spyOn(component, 'toggleSection').and.callThrough();
spyOn(menuService, 'toggleActiveSection');
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
// dispatch the (keyup.enter) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
});
it('should call activateSection on the menuService', () => {
expect(menuService.activateSection).toHaveBeenCalled();
expect(component.toggleSection).toHaveBeenCalled();
expect(menuService.toggleActiveSection).toHaveBeenCalled();
});
});
describe('when Enter key is pressed on section header (while active)', () => {
beforeEach(() => {
spyOn(menuService, 'deactivateSection');
spyOn(component, 'toggleSection').and.callThrough();
spyOn(menuService, 'toggleActiveSection');
// Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
// dispatch the (keyup.enter) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
});
it('should call deactivateSection on the menuService', () => {
expect(menuService.deactivateSection).toHaveBeenCalled();
it('should call toggleSection on the menuService', () => {
expect(component.toggleSection).toHaveBeenCalled();
expect(menuService.toggleActiveSection).toHaveBeenCalled();
});
});
describe('when spacebar is pressed on section header (while inactive)', () => {
beforeEach(() => {
spyOn(menuService, 'activateSection');
spyOn(component, 'toggleSection').and.callThrough();
spyOn(menuService, 'toggleActiveSection');
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
// dispatch the (keyup.space) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
});
it('should call activateSection on the menuService', () => {
expect(menuService.activateSection).toHaveBeenCalled();
it('should call toggleSection on the menuService', () => {
expect(component.toggleSection).toHaveBeenCalled();
expect(menuService.toggleActiveSection).toHaveBeenCalled();
});
});
describe('when spacebar is pressed on section header (while active)', () => {
beforeEach(() => {
spyOn(menuService, 'deactivateSection');
spyOn(component, 'toggleSection').and.callThrough();
spyOn(menuService, 'toggleActiveSection');
// Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
// dispatch the (keyup.space) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
});
it('should call deactivateSection on the menuService', () => {
expect(menuService.deactivateSection).toHaveBeenCalled();
});
});
describe('when a click occurs on the section header', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > button'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should not call toggleActiveSection on the menuService', () => {
expect(menuService.toggleActiveSection).not.toHaveBeenCalled();
it('should call toggleSection on the menuService', () => {
expect(component.toggleSection).toHaveBeenCalled();
expect(menuService.toggleActiveSection).toHaveBeenCalled();
});
});
});
@@ -195,7 +213,7 @@ describe('ExpandableNavbarSectionComponent', () => {
describe('when the mouse enters the section header', () => {
beforeEach(() => {
spyOn(menuService, 'activateSection');
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > button'));
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-wrapper"]'));
sidebarToggler.triggerEventHandler('mouseenter', {
preventDefault: () => {/**/
}
@@ -210,7 +228,7 @@ describe('ExpandableNavbarSectionComponent', () => {
describe('when the mouse leaves the section header', () => {
beforeEach(() => {
spyOn(menuService, 'deactivateSection');
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > button'));
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-wrapper"]'));
sidebarToggler.triggerEventHandler('mouseleave', {
preventDefault: () => {/**/
}
@@ -225,7 +243,7 @@ describe('ExpandableNavbarSectionComponent', () => {
describe('when a click occurs on the section header link', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > button'));
const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}

View File

@@ -1,10 +1,11 @@
import { Component, Inject, Injector, OnInit } from '@angular/core';
import { Component, HostListener, Inject, Injector, OnInit } from '@angular/core';
import { NavbarSectionComponent } from '../navbar-section/navbar-section.component';
import { MenuService } from '../../shared/menu/menu.service';
import { slide } from '../../shared/animations/slide';
import { first } from 'rxjs/operators';
import { HostWindowService } from '../../shared/host-window.service';
import { MenuID } from '../../shared/menu/menu-id.model';
import { Observable } from 'rxjs';
/**
* Represents an expandable section in the navbar
@@ -21,12 +22,42 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
*/
menuID = MenuID.PUBLIC;
/**
* True if mouse has entered the menu section toggler
*/
mouseEntered = false;
/**
* True if screen size was small before a resize event
*/
wasMobile = undefined;
/**
* Observable that emits true if the screen is small, false otherwise
*/
isMobile$: Observable<boolean>;
@HostListener('window:resize', ['$event'])
onResize() {
this.isMobile$.pipe(
first()
).subscribe((isMobile) => {
// When switching between desktop and mobile active sections should be deactivated
if (isMobile !== this.wasMobile) {
this.wasMobile = isMobile;
this.menuService.deactivateSection(this.menuID, this.section.id);
this.mouseEntered = false;
}
});
}
constructor(@Inject('sectionDataProvider') menuSection,
protected menuService: MenuService,
protected injector: Injector,
private windowService: HostWindowService
) {
super(menuSection, menuService, injector);
this.isMobile$ = this.windowService.isMobile();
}
ngOnInit() {
@@ -34,48 +65,42 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
}
/**
* Overrides the super function that activates this section (triggered on hover)
* Has an extra check to make sure the section can only be activated on non-mobile devices
* @param {Event} event The user event that triggered this function
* When the mouse enters the section toggler activate the menu section
* @param $event
* @param isActive
*/
activateSection(event): void {
this.windowService.isXsOrSm().pipe(
onMouseEnter($event: Event, isActive: boolean) {
this.isMobile$.pipe(
first()
).subscribe((isMobile) => {
if (!isMobile) {
super.activateSection(event);
if (!isMobile && !isActive && !this.mouseEntered) {
this.activateSection($event);
}
this.mouseEntered = true;
});
}
/**
* Overrides the super function that deactivates this section (triggered on hover)
* Has an extra check to make sure the section can only be deactivated on non-mobile devices
* @param {Event} event The user event that triggered this function
* When the mouse leaves the section toggler deactivate the menu section
* @param $event
* @param isActive
*/
deactivateSection(event): void {
this.windowService.isXsOrSm().pipe(
onMouseLeave($event: Event, isActive: boolean) {
this.isMobile$.pipe(
first()
).subscribe((isMobile) => {
if (!isMobile) {
super.deactivateSection(event);
if (!isMobile && isActive && this.mouseEntered) {
this.deactivateSection($event);
}
this.mouseEntered = false;
});
}
/**
* Overrides the super function that toggles this section (triggered on click)
* Has an extra check to make sure the section can only be toggled on mobile devices
* @param {Event} event The user event that triggered this function
* returns the ID of the DOM element representing the navbar section
* @param sectionId
*/
toggleSection(event): void {
event.preventDefault();
this.windowService.isXsOrSm().pipe(
first()
).subscribe((isMobile) => {
if (isMobile) {
super.toggleSection(event);
}
});
expandableNavbarSectionId(sectionId: string) {
return `expandable-navbar-section-${sectionId}-dropdown`;
}
}

View File

@@ -1,3 +1,5 @@
<div class="nav-item navbar-section text-md-center">
<li role="presentation"
class="ds-menu-item-wrapper text-md-center"
[id]="'navbar-section-' + section.id">
<ng-container *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</div>
</li>

View File

@@ -1,5 +1,5 @@
.navbar-section {
display: flex;
align-items: center;
height: 100%;
:host {
li.ds-menu-item-wrapper {
}
}

View File

@@ -1,16 +1,19 @@
<nav [ngClass]="{'open': !(menuCollapsed | async)}" [@slideMobileNav]="!(windowService.isXsOrSm() | async) ? 'default' : ((menuCollapsed | async) ? 'collapsed' : 'expanded')"
class="navbar navbar-light navbar-expand-md p-md-0 navbar-container" role="navigation" [attr.aria-label]="'nav.main.description' | translate">
<nav [ngClass]="{'open': !(menuCollapsed | async)}"
[@slideMobileNav]="!(windowService.isXsOrSm() | async) ? 'default' : ((menuCollapsed | async) ? 'collapsed' : 'expanded')"
class="navbar navbar-light navbar-expand-md px-md-0 pt-md-0 pt-3 navbar-container" role="navigation"
[attr.aria-label]="'nav.main.description' | translate" id="main-navbar">
<!-- TODO remove navbar-container class when https://github.com/twbs/bootstrap/issues/24726 is fixed -->
<div class="navbar-inner-container w-100" [class.container]="!(isMobile$ | async)">
<div class="reset-padding-md w-100">
<div class="w-100">
<div id="collapsingNav">
<ul class="navbar-nav navbar-navigation mr-auto shadow-none">
<li *ngIf="(isMobile$ | async) && (isAuthenticated$ | async)">
<ng-container *ngIf="(isMobile$ | async) && (isAuthenticated$ | async)">
<ds-themed-user-menu [inExpandableNavbar]="true"></ds-themed-user-menu>
</li>
<li *ngFor="let section of (sections | async)">
<ng-container *ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id)?.injector;"></ng-container>
</li>
</ng-container>
<ul class="navbar-nav align-items-md-center mr-auto shadow-none gapx-3">
<ng-container *ngFor="let section of (sections | async)">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id)?.injector;"></ng-container>
</ng-container>
</ul>
</div>
</div>

View File

@@ -1,7 +1,10 @@
:host {
--ds-collapsible-navbar-height: auto;
nav.navbar {
background-color: var(--ds-navbar-bg);
align-items: baseline;
padding: 0;
}
/** Mobile menu styling **/
@@ -23,8 +26,8 @@
@media screen and (min-width: map-get($grid-breakpoints, md)) {
.reset-padding-md {
margin-left: calc(var(--bs-spacer) / -2);
margin-right: calc(var(--bs-spacer) / -2);
margin-left: calc(var(--bs-spacer) / -1);
margin-right: calc(var(--bs-spacer) / -1);
}
}
@@ -38,9 +41,20 @@
}
}
.navbar-nav {
::ng-deep a.nav-link, .btn.nav-link {
#main-navbar ::ng-deep {
.ds-menu-item, .ds-menu-toggler-wrapper {
white-space: nowrap;
text-decoration: none;
}
.dropdown-menu {
padding: 0.5rem !important;
}
.ds-menu-item {
display: block;
color: var(--ds-navbar-link-color);
padding: 0.5rem 0;
&:hover, &:focus {
color: var(--ds-navbar-link-color-hover);

View File

@@ -1,12 +1,12 @@
<button (click)="skipToMainContent()" class="btn btn-primary" id="skip-to-main-content">
<button (click)="skipToMainContent()" class="sr-only" id="skip-to-main-content">
{{ 'root.skip-to-content' | translate }}
</button>
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader" [@slideSidebarPadding]="{
value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'),
params: {collapsedSidebarWidth: (collapsedSidebarWidth | async), totalSidebarWidth: (totalSidebarWidth | async)}
value: (!(isSidebarVisible$ | async) ? 'hidden' : (slideSidebarOver$ | async) ? 'unpinned' : 'pinned'),
params: { collapsedWidth: (collapsedSidebarWidth$ | async), expandedWidth: (expandedSidebarWidth$ | async) }
}">
<ds-themed-admin-sidebar></ds-themed-admin-sidebar>
<ds-themed-admin-sidebar [expandedSidebarWidth$]="expandedSidebarWidth$" [collapsedSidebarWidth$]="collapsedSidebarWidth$"></ds-themed-admin-sidebar>
<div class="inner-wrapper">
<ds-system-wide-alert-banner></ds-system-wide-alert-banner>
<ds-themed-header-navbar-wrapper></ds-themed-header-navbar-wrapper>

View File

@@ -1,25 +1,17 @@
import { map, startWith } from 'rxjs/operators';
import { Component, Inject, Input, OnInit } from '@angular/core';
import { first, map, skipWhile, startWith } from 'rxjs/operators';
import { Component, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { MetadataService } from '../core/metadata/metadata.service';
import { HostWindowState } from '../shared/search/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from '../core/services/window.service';
import { AuthService } from '../core/auth/auth.service';
import { CSSVariableService } from '../shared/sass-helper/css-variable.service';
import { MenuService } from '../shared/menu/menu.service';
import { HostWindowService } from '../shared/host-window.service';
import { ThemeConfig } from '../../config/theme.config';
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
import { environment } from '../../environments/environment';
import { slideSidebarPadding } from '../shared/animations/slide';
import { MenuID } from '../shared/menu/menu-id.model';
import { getPageInternalServerErrorRoute } from '../app-routing-paths';
import { hasValueOperator } from '../shared/empty.util';
import { INotificationBoardOptions } from 'src/config/notifications-config.interfaces';
@Component({
selector: 'ds-root',
@@ -28,13 +20,13 @@ import { hasValueOperator } from '../shared/empty.util';
animations: [slideSidebarPadding],
})
export class RootComponent implements OnInit {
sidebarVisible: Observable<boolean>;
slideSidebarOver: Observable<boolean>;
collapsedSidebarWidth: Observable<string>;
totalSidebarWidth: Observable<string>;
theme: Observable<ThemeConfig> = of({} as any);
notificationOptions;
models;
isSidebarVisible$: Observable<boolean>;
slideSidebarOver$: Observable<boolean>;
collapsedSidebarWidth$: Observable<string>;
expandedSidebarWidth$: Observable<string>;
notificationOptions: INotificationBoardOptions;
models: any;
/**
* Whether or not to show a full screen loader
@@ -47,12 +39,6 @@ export class RootComponent implements OnInit {
@Input() shouldShowRouteLoader: boolean;
constructor(
@Inject(NativeWindowService) private _window: NativeWindowRef,
private translate: TranslateService,
private store: Store<HostWindowState>,
private metadata: MetadataService,
private angulartics2DSpace: Angulartics2DSpace,
private authService: AuthService,
private router: Router,
private cssService: CSSVariableService,
private menuService: MenuService,
@@ -62,13 +48,19 @@ export class RootComponent implements OnInit {
}
ngOnInit() {
this.sidebarVisible = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
this.isSidebarVisible$ = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
this.collapsedSidebarWidth = this.cssService.getVariable('--ds-collapsed-sidebar-width').pipe(hasValueOperator());
this.totalSidebarWidth = this.cssService.getVariable('--ds-total-sidebar-width').pipe(hasValueOperator());
this.expandedSidebarWidth$ = this.cssService.getVariable('--ds-admin-sidebar-total-width').pipe(
skipWhile((val) => !val),
first(),
);
this.collapsedSidebarWidth$ = this.cssService.getVariable('--ds-admin-sidebar-fixed-element-width').pipe(
skipWhile((val) => !val),
first(),
);
const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN);
this.slideSidebarOver = combineLatestObservable([sidebarCollapsed, this.windowService.isXsOrSm()])
this.slideSidebarOver$ = combineLatestObservable([sidebarCollapsed, this.windowService.isXsOrSm()])
.pipe(
map(([collapsed, mobile]) => collapsed || mobile),
startWith(true),

View File

@@ -1,4 +1,4 @@
import { animate, animateChild, group, query, state, style, transition, trigger } from '@angular/animations';
import { animate, group, state, style, transition, trigger } from '@angular/animations';
export const slide = trigger('slide', [
state('expanded', style({ height: '*' })),
@@ -12,45 +12,35 @@ export const slide = trigger('slide', [
export const slideMobileNav = trigger('slideMobileNav', [
state('expanded', style({ height: 'auto', 'min-height': '100vh' })),
state('expanded', style({ height: '*', minHeight: '*' })),
state('collapsed', style({ height: 0 })),
state('collapsed', style({ height: 0, minHeight: 0 })),
transition('expanded <=> collapsed', animate('300ms'))
]);
const collapsedStyle = style({ marginLeft: '-{{ sidebarWidth }}' });
const expandedStyle = style({ marginLeft: '0' });
const options = { params: { sidebarWidth: '*' } };
const collapsedSidebarStyle = style({ maxWidth: '{{collapsedWidth}}' });
const expandedSidebarStyle = style({ maxWidth: '{{expandedWidth}}' });
const unpinnedSidebarPageStyle = style({ paddingLeft: '{{collapsedWidth}}' });
const pinnedSidebarPageStyle = style({ paddingLeft: '{{expandedWidth}}' });
const hiddenSidebarPageStyle = style({ paddingLeft: '0' });
const options = { params: { collapsedWidth: '*', expandedWidth: '*' } };
const animation = '300ms ease-in-out';
export const slideSidebar = trigger('slideSidebar', [
transition('expanded => collapsed',
group(
[
query('@*', animateChild()),
query('.sidebar-collapsible', expandedStyle, options),
query('.sidebar-collapsible', animate('300ms ease-in-out', collapsedStyle))
],
)),
transition('collapsed => expanded',
group(
[
query('@*', animateChild()),
query('.sidebar-collapsible', collapsedStyle),
query('.sidebar-collapsible', animate('300ms ease-in-out', expandedStyle), options)
]
))
state('collapsed', collapsedSidebarStyle, options),
state('expanded', expandedSidebarStyle, options),
transition('expanded => collapsed', animate(animation, collapsedSidebarStyle)),
transition('collapsed => expanded', animate(animation, expandedSidebarStyle)),
]);
export const slideSidebarPadding = trigger('slideSidebarPadding', [
state('hidden', style({ paddingLeft: 0 })),
state('shown', style({ paddingLeft: '{{ collapsedSidebarWidth }}' }), { params: { collapsedSidebarWidth: '*' } }),
state('expanded', style({ paddingLeft: '{{ totalSidebarWidth }}' }), { params: { totalSidebarWidth: '*' } }),
transition('hidden <=> shown', [animate('200ms')]),
transition('hidden <=> expanded', [animate('200ms')]),
transition('shown <=> expanded', [animate('200ms')]),
state('hidden', hiddenSidebarPageStyle),
state('unpinned', unpinnedSidebarPageStyle, options),
state('pinned', pinnedSidebarPageStyle, options),
transition('hidden <=> unpinned', animate(animation)),
transition('hidden <=> pinned', animate(animation)),
transition('unpinned <=> pinned', animate(animation)),
]);
export const expandSearchInput = trigger('toggleAnimation', [

View File

@@ -1,40 +1,52 @@
<ul class="navbar-nav" [ngClass]="{'mr-auto': (isXsOrSm$ | async)}">
<li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item"
<div class="navbar-nav mr-auto" *ngIf="!(isMobile$ | async); else mobileButtons" data-test="auth-nav">
<div *ngIf="!(isAuthenticated | async) && (showAuth | async)"
class="nav-item"
(click)="$event.stopPropagation();">
<div ngbDropdown #loginDrop display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<button class="btn btn-link dropdownLogin px-0.5" [attr.aria-label]="'nav.login' |translate"
<div ngbDropdown #loginDrop="ngbDropdown" display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="javascript:void(0);" class="dropdownLogin px-0.5" [attr.aria-label]="'nav.login' |translate"
(click)="$event.preventDefault()" [attr.data-test]="'login-menu' | dsBrowserOnly"
ngbDropdownToggle>
{{ 'nav.login' | translate }}
</button>
<div class="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu
role="menuitem"
aria-haspopup="menu"
aria-controls="loginDropdownMenu"
[attr.aria-expanded]="loginDrop.isOpen()"
ngbDropdownToggle>{{ 'nav.login' | translate }}</a>
<div id="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu
role="menu"
[attr.aria-label]="'nav.login' | translate">
<ds-themed-log-in
[isStandalonePage]="false"></ds-themed-log-in>
</div>
</div>
</li>
<li *ngIf="!(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item">
<a routerLink="/login" routerLinkActive="active" class="loginLink px-0.5">
</div>
<div *ngIf="(isAuthenticated | async) && (showAuth | async)" class="nav-item">
<div ngbDropdown display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="javascript:void(0);"
role="menuitem"
[attr.aria-label]="'nav.user-profile-menu-and-logout' | translate"
aria-controls="user-menu-dropdown"
(click)="$event.preventDefault()" [title]="'nav.user-profile-menu-and-logout' | translate"
class="dropdownLogout px-1"
[attr.data-test]="'user-menu' | dsBrowserOnly"
ngbDropdownToggle>
<i class="fas fa-user-circle fa-lg fa-fw"></i></a>
<ul id="logoutDropdownMenu" class="p-3" ngbDropdownMenu role="presentation">
<ds-themed-user-menu></ds-themed-user-menu>
</ul>
</div>
</div>
</div>
<ng-template #mobileButtons>
<div data-test="auth-nav">
<a *ngIf="!(isAuthenticated | async)" routerLink="/login" routerLinkActive="active" class="loginLink px-0.5" role="button">
{{ 'nav.login' | translate }}<span class="sr-only">(current)</span>
</a>
</li>
<li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item">
<div ngbDropdown display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<button role="button" [attr.aria-label]="'nav.user-profile-menu-and-logout' |translate" (click)="$event.preventDefault()" [title]="'nav.user-profile-menu-and-logout' | translate" class="btn btn-link dropdownLogout px-1" [attr.data-test]="'user-menu' | dsBrowserOnly" ngbDropdownToggle>
<i class="fas fa-user-circle fa-lg fa-fw"></i>
</button>
<div class="logoutDropdownMenu" ngbDropdownMenu [attr.aria-label]="'nav.user-profile-menu-and-logout' |translate">
<ds-themed-user-menu></ds-themed-user-menu>
</div>
</div>
</li>
<li *ngIf="(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item">
<a role="button" [attr.aria-label]="'nav.logout' |translate" [title]="'nav.logout' | translate" routerLink="/logout" routerLinkActive="active" class="logoutLink px-1">
<a *ngIf="(isAuthenticated | async)" role="button" [attr.aria-label]="'nav.logout' |translate" [title]="'nav.logout' | translate" routerLink="/logout" routerLinkActive="active" class="logoutLink px-1">
<i class="fas fa-sign-out-alt fa-lg fa-fw"></i>
<span class="sr-only">(current)</span>
</a>
</li>
</ul>
</div>
</ng-template>
<!-- Do not use ul/li in this menu as it breaks e2e accessibility tests -->

View File

@@ -1,9 +1,9 @@
.loginDropdownMenu, .logoutDropdownMenu {
#loginDropdownMenu, #logoutDropdownMenu {
min-width: 330px;
z-index: 1002;
}
.loginDropdownMenu {
#loginDropdownMenu {
min-height: 75px;
}

View File

@@ -16,6 +16,7 @@ import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model';
import { AuthService } from '../../core/auth/auth.service';
import { of } from 'rxjs';
import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
describe('AuthNavMenuComponent', () => {
@@ -75,7 +76,8 @@ describe('AuthNavMenuComponent', () => {
strictActionImmutability: false
}
}),
TranslateModule.forRoot()
TranslateModule.forRoot(),
NgbDropdownModule
],
declarations: [
AuthNavMenuComponent,
@@ -83,7 +85,7 @@ describe('AuthNavMenuComponent', () => {
],
providers: [
{ provide: HostWindowService, useValue: window },
{ provide: AuthService, useValue: authService }
{ provide: AuthService, useValue: authService },
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
@@ -117,10 +119,10 @@ describe('AuthNavMenuComponent', () => {
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
const navMenuSelector = '[data-test="auth-nav"]';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
const navMenuItemSelector = '.nav-item';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
afterEach(() => {
@@ -155,10 +157,10 @@ describe('AuthNavMenuComponent', () => {
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
const navMenuSelector = '[data-test="auth-nav"]';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
const navMenuItemSelector = '.nav-item';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
@@ -197,10 +199,10 @@ describe('AuthNavMenuComponent', () => {
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
const navMenuSelector = '[data-test="auth-nav"]';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
const navMenuItemSelector = '.nav-item';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
@@ -210,7 +212,7 @@ describe('AuthNavMenuComponent', () => {
});
it('should render login dropdown menu', () => {
const loginDropdownMenu = deNavMenuItem.query(By.css('div.loginDropdownMenu'));
const loginDropdownMenu = deNavMenuItem.query(By.css('div#loginDropdownMenu'));
expect(loginDropdownMenu.nativeElement).toBeDefined();
});
});
@@ -236,10 +238,10 @@ describe('AuthNavMenuComponent', () => {
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
const navMenuSelector = '[data-test="auth-nav"]';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
const navMenuItemSelector = '.nav-item';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
@@ -309,11 +311,8 @@ describe('AuthNavMenuComponent', () => {
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
const navMenuSelector = '[data-test="auth-nav"]';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
afterEach(() => {
@@ -322,7 +321,7 @@ describe('AuthNavMenuComponent', () => {
});
it('should render login link', () => {
const loginDropdownMenu = deNavMenuItem.query(By.css('.loginLink'));
const loginDropdownMenu = deNavMenu.query(By.css('.loginLink'));
expect(loginDropdownMenu.nativeElement).toBeDefined();
});
});
@@ -345,11 +344,8 @@ describe('AuthNavMenuComponent', () => {
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
const navMenuSelector = '[data-test="auth-nav"]';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
afterEach(() => {
@@ -358,7 +354,7 @@ describe('AuthNavMenuComponent', () => {
});
it('should render logout link', inject([Store], (store: Store<AppState>) => {
const logoutDropdownMenu = deNavMenuItem.query(By.css('a.logoutLink'));
const logoutDropdownMenu = deNavMenu.query(By.css('a.logoutLink'));
expect(logoutDropdownMenu.nativeElement).toBeDefined();
}));
});

View File

@@ -32,7 +32,7 @@ export class AuthNavMenuComponent implements OnInit {
*/
public loading: Observable<boolean>;
public isXsOrSm$: Observable<boolean>;
public isMobile$: Observable<boolean>;
public showAuth = observableOf(false);
@@ -44,7 +44,7 @@ export class AuthNavMenuComponent implements OnInit {
private windowService: HostWindowService,
private authService: AuthService
) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
this.isMobile$ = this.windowService.isMobile();
}
ngOnInit(): void {

View File

@@ -1,15 +1,32 @@
<ds-themed-loading *ngIf="(loading$ | async)"></ds-themed-loading>
<div *ngIf="!(loading$ | async)">
<span class="dropdown-item-text" [class.pl-0]="inExpandableNavbar">
<ul *ngIf="!(loading$ | async)" class="user-menu" role="menu"
[ngClass]="{ 'pb-2 mb-2 border-bottom' : inExpandableNavbar }"
[attr.aria-label]="'nav.user-profile-menu-and-logout' |translate" id="user-menu-dropdown">
<li class="ds-menu-item-wrapper username-email-wrapper" role="presentation">
{{dsoNameService.getName(user$ | async)}}<br>
<span class="text-muted">{{(user$ | async)?.email}}</span>
</span>
<a [ngClass]="inExpandableNavbar ? 'nav-item nav-link' : 'dropdown-item'" [routerLink]="[profileRoute]" routerLinkActive="active">{{'nav.profile' | translate}}</a>
<a [ngClass]="inExpandableNavbar ? 'nav-item nav-link' : 'dropdown-item'" [routerLink]="[mydspaceRoute]" routerLinkActive="active">{{'nav.mydspace' | translate}}</a>
<a [ngClass]="inExpandableNavbar ? 'nav-item nav-link' : 'dropdown-item'" [routerLink]="[subscriptionsRoute]" routerLinkActive="active">{{'nav.subscriptions' | translate}}</a>
<div class="dropdown-divider"></div>
<ds-log-out *ngIf="!inExpandableNavbar" data-test="log-out-component"></ds-log-out>
</div>
</li>
<li class="ds-menu-item-wrapper" role="presentation">
<a class="ds-menu-item" role="menuitem"
[routerLink]="[profileRoute]"
routerLinkActive="active">{{'nav.profile' | translate}}</a>
</li>
<li class="ds-menu-item-wrapper" role="presentation">
<a class="ds-menu-item" role="menuitem"
[routerLink]="[mydspaceRoute]"
routerLinkActive="active">{{'nav.mydspace' | translate}}</a>
</li>
<li class="ds-menu-item-wrapper" role="presentation">
<a class="ds-menu-item" role="menuitem"
[routerLink]="[subscriptionsRoute]"
routerLinkActive="active">{{'nav.subscriptions' | translate}}</a>
</li>
<ng-container *ngIf="!inExpandableNavbar">
<li class="dropdown-divider" aria-hidden="true"></li>
<li class="ds-menu-item-wrapper" role="presentation">
<ds-log-out data-test="log-out-component"></ds-log-out>
</li>
</ng-container>
</ul>

View File

@@ -0,0 +1,12 @@
:host {
ul.user-menu {
list-style: none;
margin: 0;
padding: 0;
}
.ds-menu-item {
display: inline-block;
padding: 0.25rem 0;
}
}

View File

@@ -140,7 +140,7 @@ describe('UserMenuComponent', () => {
fixture.detectChanges();
deUserMenu = fixture.debugElement.query(By.css('div'));
deUserMenu = fixture.debugElement.query(By.css('ul#user-menu-dropdown'));
}));
afterEach(() => {
@@ -164,7 +164,7 @@ describe('UserMenuComponent', () => {
it('should display user name and email', () => {
const username = 'User Test';
const email = 'test@test.com';
const span = deUserMenu.query(By.css('.dropdown-item-text'));
const span = deUserMenu.query(By.css('.username-email-wrapper'));
expect(span).toBeDefined();
expect(span.nativeElement.innerHTML).toContain(username);
expect(span.nativeElement.innerHTML).toContain(email);

View File

@@ -1,13 +1,16 @@
<div ngbDropdown class="navbar-nav" *ngIf="moreThanOneLanguage" display="dynamic" placement="bottom-right">
<a href="javascript:void(0);" role="button"
<a href="javascript:void(0);" role="menuitem"
[attr.aria-label]="'nav.language' |translate"
aria-controls="language-menu-list"
aria-haspopup="menu"
[title]="'nav.language' | translate"
(click)="$event.preventDefault()" data-toggle="dropdown" ngbDropdownToggle
tabindex="0">
<i class="fas fa-globe-asia fa-lg fa-fw"></i>
</a>
<ul ngbDropdownMenu class="dropdown-menu" [attr.aria-label]="'nav.language' |translate">
<ul ngbDropdownMenu class="dropdown-menu" [attr.aria-label]="'nav.language' |translate" id="language-menu-list" role="menu">
<li class="dropdown-item" tabindex="0" #langSelect *ngFor="let lang of translate.getLangs()"
role="menuitem"
(keyup.enter)="useLang(lang)"
(click)="useLang(lang)"
[class.active]="lang === translate.currentLang">

View File

@@ -28,10 +28,10 @@
</form>
<div class="mt-2">
<a class="dropdown-item" *ngIf="canRegister$ | async" [routerLink]="[getRegisterRoute()]" [attr.data-test]="'register' | dsBrowserOnly">
<a class="dropdown-item" *ngIf="canRegister$ | async" [routerLink]="[getRegisterRoute()]" [attr.data-test]="'register' | dsBrowserOnly" role="menuitem">
{{ 'login.form.new-user' | translate }}
</a>
<a class="dropdown-item" [routerLink]="[getForgotRoute()]" [attr.data-test]="'forgot' | dsBrowserOnly">
<a class="dropdown-item" [routerLink]="[getForgotRoute()]" [attr.data-test]="'forgot' | dsBrowserOnly" role="menuitem">
{{ 'login.form.forgot-password' | translate }}
</a>
</div>

View File

@@ -1,4 +1,5 @@
<a class="nav-item nav-link"
<a class="ds-menu-item" role="menuitem"
routerLinkActive="active"
[ngClass]="{'disabled': !hasLink }"
[attr.aria-disabled]="!hasLink"
[href]="href"

View File

@@ -9,7 +9,8 @@ import { MenuItemType } from '../menu-item-type.model';
*/
@Component({
selector: 'ds-external-link-menu-item',
templateUrl: './external-link-menu-item.component.html'
styleUrls: ['./menu-item.component.scss'],
templateUrl: './external-link-menu-item.component.html',
})
@rendersMenuItemForType(MenuItemType.EXTERNAL)
export class ExternalLinkMenuItemComponent implements OnInit {

View File

@@ -1,8 +1,7 @@
<a class="nav-item nav-link"
<a class="ds-menu-item" role="menuitem"
[ngClass]="{ 'disabled': !hasLink || item.disabled }"
[attr.aria-disabled]="!hasLink || item.disabled"
[title]="item.text | translate"
[attr.data-test]="'link-menu-item.'+item.text"
[routerLink]="getRouterLink()"
[queryParams]="item.queryParams"
(click)="$event.stopPropagation()"

View File

@@ -10,7 +10,8 @@ import { Router } from '@angular/router';
*/
@Component({
selector: 'ds-link-menu-item',
templateUrl: './link-menu-item.component.html'
styleUrls: ['./menu-item.component.scss'],
templateUrl: './link-menu-item.component.html',
})
@rendersMenuItemForType(MenuItemType.LINK)
export class LinkMenuItemComponent implements OnInit {

View File

@@ -0,0 +1,5 @@
:host {
.ds-menu-item {
display: inline-block;
}
}

View File

@@ -1,7 +1,7 @@
<a *ngIf="!item.disabled"
class="ds-menu-item" role="menuitem"
routerLinkActive="active"
href="javascript:void(0);"
class="nav-item nav-link"
role="button"
[title]="item.text | translate"
(click)="activate($event)"
(keyup.space)="activate($event)"

View File

@@ -45,7 +45,7 @@ describe('OnClickMenuItemComponent', () => {
});
it('should call the function on the item when clicked', () => {
debugElement.query(By.css('a.nav-link')).triggerEventHandler('click', new Event(('click')));
debugElement.query(By.css('a.ds-menu-item')).triggerEventHandler('click', new Event(('click')));
expect(item.function).toHaveBeenCalled();
});
});

View File

@@ -8,7 +8,7 @@ import { MenuItemType } from '../menu-item-type.model';
*/
@Component({
selector: 'ds-onclick-menu-item',
styleUrls: ['./onclick-menu-item.component.scss'],
styleUrls: ['./menu-item.component.scss', './onclick-menu-item.component.scss'],
templateUrl: './onclick-menu-item.component.html'
})
@rendersMenuItemForType(MenuItemType.ONCLICK)

View File

@@ -1 +1 @@
<span [class.disabled]="item.disabled">{{item.text | translate}}</span>
<span class="ds-menu-item" [class.disabled]="item.disabled">{{item.text | translate}}</span>

View File

@@ -8,6 +8,7 @@ import { MenuItemType } from '../menu-item-type.model';
*/
@Component({
selector: 'ds-text-menu-item',
styleUrls: ['./menu-item.component.scss'],
templateUrl: './text-menu-item.component.html',
})
@rendersMenuItemForType(MenuItemType.TEXT)

View File

@@ -1,4 +1,5 @@
import {of as observableOf, Observable } from 'rxjs';
import { WidthCategory } from '../host-window.service';
// declare a stub service
export class HostWindowServiceStub {
@@ -24,4 +25,8 @@ export class HostWindowServiceStub {
isMobile(): Observable<boolean> {
return this.isXs();
}
isUpTo(maxWidthCat: WidthCategory): Observable<boolean> {
return this.isXs();
}
}

View File

@@ -53,6 +53,9 @@ export class MenuServiceStub {
deactivateSection(): void { /***/
}
toggleSection(): void { /***/
}
addSection(menuID: MenuID, section: MenuSection): void { /***/
}

View File

@@ -1,5 +1,5 @@
<div *ngIf="(systemWideAlert$ |async)?.active">
<div class="rounded-0 alert alert-warning w100">
<div class="rounded-0 alert alert-warning w100 m-0 px-1">
<div class="container">
<span class="font-weight-bold">
<span *ngIf="(countDownDays|async) > 0 || (countDownHours| async) > 0 || (countDownMinutes|async) > 0 ">

View File

@@ -2854,6 +2854,8 @@
"logout.title": "Logout",
"menu.header.nav.description": "Admin navigation bar",
"menu.header.admin": "Management",
"menu.header.image.logo": "Repository logo",
@@ -3100,6 +3102,8 @@
"mydspace.view-btn": "View",
"nav.expandable-navbar-section-suffix": "(submenu)",
"nav.browse.header": "All of DSpace",
"nav.community-browse.header": "By Community",

View File

@@ -1,9 +1,6 @@
/** Help Variables **/
$fa-fixed-width: 1.25rem !default;
$icon-padding: 1rem !default;
$collapsed-sidebar-width: calculatePx($fa-fixed-width + (2 * $icon-padding)) !default;
$sidebar-items-width: 250px !default;
$total-sidebar-width: $collapsed-sidebar-width + $sidebar-items-width !default;
/* Fonts */
// Starting this url with a caret (^) allows it to be a relative path based on UI's deployment path

View File

@@ -40,13 +40,24 @@
$admin-sidebar-bg: darken(#2B4E72, 17%);
$admin-sidebar-active-bg: darken($admin-sidebar-bg, 3%);
$admin-sidebar-fixed-element-width: 55px !default;
$admin-sidebar-collapsible-element-width: 250px !default;
$admin-sidebar-total-width: $admin-sidebar-fixed-element-width + $admin-sidebar-collapsible-element-width !default;
--ds-admin-sidebar-bg: #{$admin-sidebar-bg};
--ds-admin-sidebar-active-bg: #{$admin-sidebar-active-bg};
--ds-admin-sidebar-header-bg: #{darken($admin-sidebar-bg, 7%)};
--ds-admin-sidebar-fixed-element-width: #{$admin-sidebar-fixed-element-width};
--ds-admin-sidebar-fixed-element-z-index: 10;
--ds-admin-sidebar-collapsible-element-width: #{$admin-sidebar-collapsible-element-width};
--ds-admin-sidebar-total-width: #{$admin-sidebar-total-width};
--ds-admin-sidebar-link-color: #{$navbar-dark-color};
--ds-admin-sidebar-link-hover-color: #{$navbar-dark-hover-color};
--ds-dark-scrollbar-bg: #{$admin-sidebar-active-bg};
--ds-dark-scrollbar-alt-bg: #{lighten($admin-sidebar-active-bg, 2%)};
--ds-dark-scrollbar-fg: #47495d;
--ds-dark-scrollbar-width: 8px;
--ds-submission-sections-margin-bottom: .5rem;
@@ -69,9 +80,6 @@
--ds-fa-fixed-width: #{$fa-fixed-width};
--ds-icon-padding: #{$icon-padding};
--ds-collapsed-sidebar-width: #{$collapsed-sidebar-width};
--ds-sidebar-items-width: #{$sidebar-items-width};
--ds-total-sidebar-width: #{$total-sidebar-width};
--ds-top-footer-bg: #{$gray-200};
--ds-footer-bg: #{theme-color('primary')};

View File

@@ -33,11 +33,6 @@ body {
margin: 0;
}
ds-admin-sidebar {
position: fixed;
z-index: var(--ds-sidebar-z-index);
}
.sticky-top {
z-index: 0;
}

View File

@@ -14,7 +14,7 @@
@mixin dark-scrollbar() {
&::-webkit-scrollbar {
width: 8px;
width: var(--ds-dark-scrollbar-width);
height: 3px;
}
&::-webkit-scrollbar-button {

View File

@@ -1,3 +1,9 @@
<div [class.open]="!(isNavBarCollapsed | async)" id="header-navbar-wrapper">
<div id="header-navbar-wrapper"><!-- [class.open]="!(isNavBarCollapsed$ | async)" -->
<ds-themed-header></ds-themed-header>
<!-- Collapsible navbar (mobile only) -->
<div *ngIf="(isMobile$ | async)" id="mobile-navbar-wrapper" [@slideMobileNav]="(isNavBarCollapsed$ | async) ? 'collapsed' : 'expanded'">
<nav class="px-3" id="collapsible-mobile-navbar" [attr.aria-hidden]="(isNavBarCollapsed$ | async)" [attr.aria-label]="'nav.main.description' | translate">
<ds-themed-navbar></ds-themed-navbar>
</nav>
</div>
</div>

View File

@@ -1,7 +1,91 @@
:host {
// The header-navbar-wrapper should not have a z-index, otherwise it would cover the media viewer despite its higher z-index
position: relative;
--ds-header-navbar-border-bottom-style: solid var(--ds-header-navbar-border-bottom-height) var(--ds-header-navbar-border-bottom-color);
--ds-mobile-navbar-border-top-style: solid var(--ds-mobile-navbar-border-top-height) var(--ds-mobile-navbar-border-top-color);
--ds-collapsible-navbar-height: calc(100vh - var(--ds-header-height));
div#header-navbar-wrapper {
border-bottom: 5px var(--ds-header-navbar-border-bottom-color) solid;
// The header-navbar-wrapper should not have a z-index, otherwise it would cover the media viewer despite its higher z-index
position: relative; // required by the mobile collapsible navbar
border-bottom: var(--ds-header-navbar-border-bottom-style); // gets covered by mobile navbar wrapper, when open
div#mobile-navbar-wrapper {
width: 100%;
background-color: var(--ds-expandable-navbar-bg);
position: absolute;
z-index: var(--ds-nav-z-index);
overflow: hidden;
nav#collapsible-mobile-navbar {
// Following parameters are changed by slideMobileNav animation
min-height: var(--ds-collapsible-navbar-height);
height: auto;
border-bottom: var(--ds-header-navbar-border-bottom-style);
}
}
}
/* MENU ITEMS */
::ng-deep {
.ds-menu-item, .ds-menu-toggler-wrapper {
white-space: nowrap;
text-decoration: none;
}
nav#desktop-navbar ::ng-deep {
// desktop menu
.ds-menu-item-wrapper, .ds-menu-item, .ds-menu-toggler-wrapper {
// fill navbar height (required by dropdown menus) and center content
display: flex;
align-items: center;
height: 100%;
}
.ds-menu-item {
padding: 0.5rem;
}
.ds-menu-item, .ds-menu-toggler-wrapper {
color: var(--ds-navbar-link-color) !important;
&:hover, &:focus {
color: var(--ds-navbar-link-color-hover) !important;
}
}
// desktop submenu
.dropdown-menu {
.ds-menu-item {
}
}
}
nav#collapsible-mobile-navbar {
border-top: var(--ds-mobile-navbar-border-top-style);
padding-top: var(--ds-mobile-navbar-padding-top);
::ng-deep {
// mobile menu
.ds-menu-item {
padding: 0.25rem 0;
}
.ds-menu-item, .ds-menu-toggler-wrapper {
color: var(--ds-expandable-navbar-link-color) !important;
&:hover, &:focus {
color: var(--ds-expandable-navbar-link-color-hover) !important;
}
}
// mobile submenu
.dropdown-menu {
.ds-menu-item {
}
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { HeaderNavbarWrapperComponent as BaseComponent } from '../../../../app/header-nav-wrapper/header-navbar-wrapper.component';
import { slideMobileNav } from '../../../../app/shared/animations/slide';
/**
* This component represents a wrapper for the horizontal navbar and the header
@@ -8,6 +9,7 @@ import { HeaderNavbarWrapperComponent as BaseComponent } from '../../../../app/h
selector: 'ds-header-navbar-wrapper',
styleUrls: ['header-navbar-wrapper.component.scss'],
templateUrl: 'header-navbar-wrapper.component.html',
animations: [slideMobileNav],
})
export class HeaderNavbarWrapperComponent extends BaseComponent {
}

View File

@@ -1,25 +1,34 @@
<header class="header">
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="container navbar navbar-expand-md px-0">
<div class="d-flex flex-grow-1">
<a class="navbar-brand m-2" routerLink="/home">
<img src="assets/images/dspace-logo.svg" [attr.alt]="'menu.header.image.logo' | translate"/>
<header id="main-site-header">
<div class="container h-100 d-flex flex-row flex-nowrap justify-content-between gapx-3">
<!-- Logo and navbar wrapper -->
<div id="header-left"
[attr.role]="(isMobile$ | async) ? 'navigation' : 'presentation'"
[attr.aria-label]="(isMobile$ | async) ? ('nav.main.description' | translate) : null"
class="h-100 flex-fill d-flex flex-row flex-nowrap justify-content-start align-items-center gapx-3">
<a class="d-block" routerLink="/home" [attr.aria-label]="'home.title' | translate">
<img id="header-logo" src="assets/images/dspace-logo.svg" [attr.alt]="'menu.header.image.logo' | translate"/>
</a>
<nav *ngIf="!(isMobile$ | async)" class="navbar navbar-expand p-0 align-items-stretch align-self-stretch" id="desktop-navbar" [attr.aria-label]="'nav.main.description' | translate">
<ds-themed-navbar></ds-themed-navbar>
</nav>
</div>
<div class="navbar-buttons d-flex flex-grow-1 ml-auto justify-content-end align-items-center gapx-1">
<!-- Search bar and other menus -->
<div id="header-right" class="h-100 d-flex flex-row flex-nowrap justify-content-end align-items-center gapx-1">
<ds-themed-search-navbar></ds-themed-search-navbar>
<div role="menubar" class="h-100 d-flex flex-row flex-nowrap align-items-center gapx-1">
<ds-themed-lang-switch></ds-themed-lang-switch>
<ds-context-help-toggle></ds-context-help-toggle>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2">
<button class="navbar-toggler" type="button" (click)="toggleNavbar()"
aria-controls="collapsingNav"
aria-expanded="false" [attr.aria-label]="'nav.toggle' | translate">
<span class="navbar-toggler-icon fas fa-bars fa-fw" aria-hidden="true"></span>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
</div>
<div id="mobile-navbar-toggler" class="d-block d-lg-none ml-3" *ngIf="(isMobile$ | async)">
<button id="navbar-toggler" class="btn" type="button" (click)="toggleNavbar()"
[attr.aria-label]="'nav.toggle' | translate" aria-controls="collapsible-mobile-navbar" [attr.aria-expanded]="!(isNavBarCollapsed$ | async)">
<span class="fas fa-bars fa-fw fa-xl" aria-hidden="true"></span>
</button>
</div>
</div>
</nav>
<ds-themed-navbar></ds-themed-navbar>
</div>
</div>
</header>

View File

@@ -1,26 +1,19 @@
@media screen and (min-width: map-get($grid-breakpoints, md)) {
nav.navbar {
display: none;
}
.header {
:host {
#main-site-header {
height: var(--ds-header-height);
background-color: var(--ds-header-bg);
}
}
.navbar-brand img {
@media screen and (max-width: map-get($grid-breakpoints, md)) {
height: var(--ds-header-logo-height-xs);
img#header-logo {
height: 40px;
}
}
.navbar-toggler .navbar-toggler-icon {
background-image: none !important;
line-height: 1.5;
}
.navbar-toggler {
button#navbar-toggler {
color: var(--ds-header-icon-color);
&:hover, &:focus {
color: var(--ds-header-icon-color-hover);
}
}
}

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { HeaderComponent as BaseComponent } from '../../../../app/header/header.component';
import { Observable } from 'rxjs';
/**
* Represents the header with the logo and simple navigation
@@ -9,5 +10,11 @@ import { HeaderComponent as BaseComponent } from '../../../../app/header/header.
styleUrls: ['header.component.scss'],
templateUrl: 'header.component.html',
})
export class HeaderComponent extends BaseComponent {
export class HeaderComponent extends BaseComponent implements OnInit {
public isNavBarCollapsed$: Observable<boolean>;
ngOnInit() {
super.ngOnInit();
this.isNavBarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
}
}

View File

@@ -1,26 +1,9 @@
<nav [ngClass]="{'open': !(menuCollapsed | async)}" [@slideMobileNav]="!(windowService.isXsOrSm() | async) ? 'default' : ((menuCollapsed | async) ? 'collapsed' : 'expanded')"
class="navbar navbar-expand-md navbar-light p-0 navbar-container" role="navigation" [attr.aria-label]="'nav.main.description' | translate">
<div class="navbar-inner-container w-100 h-100" [class.container]="!(isXsOrSm$ | async)">
<a class="navbar-brand my-2" routerLink="/home">
<img src="assets/images/dspace-logo.svg" [attr.alt]="'menu.header.image.logo' | translate" />
</a>
<div id="collapsingNav" class="w-100 h-100">
<ul class="navbar-nav me-auto mb-2 mb-lg-0 h-100">
<li *ngIf="(isXsOrSm$ | async) && (isAuthenticated$ | async)">
<ds-themed-user-menu [inExpandableNavbar]="true"></ds-themed-user-menu>
</li>
<li *ngFor="let section of (sections | async)">
<ng-container *ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id)?.injector;"></ng-container>
</li>
</ul>
</div>
<div class="navbar-buttons d-flex align-items-center gapx-1">
<ds-themed-search-navbar class="navbar-collapsed"></ds-themed-search-navbar>
<ds-themed-lang-switch class="navbar-collapsed"></ds-themed-lang-switch>
<ds-context-help-toggle class="navbar-collapsed"></ds-context-help-toggle>
<ds-themed-auth-nav-menu class="navbar-collapsed"></ds-themed-auth-nav-menu>
<ds-impersonate-navbar class="navbar-collapsed"></ds-impersonate-navbar>
</div>
</div>
</nav>
<ng-container *ngIf="(isMobile$ | async) && (isAuthenticated$ | async)">
<ds-user-menu [inExpandableNavbar]="true"></ds-user-menu>
</ng-container>
<ul class="navbar-nav h-100 align-items-md-stretch gapx-3" role="menubar">
<ng-container *ngFor="let section of (sections | async)">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id)?.injector;"></ng-container>
</ng-container>
</ul>

View File

@@ -1,65 +1,3 @@
nav.navbar {
align-items: baseline;
:host {
.navbar-inner-container {
border-top: 1px var(--ds-header-navbar-border-top-color) solid;
}
}
.navbar-nav {
background-color: var(--ds-navbar-bg);
}
/** Mobile menu styling **/
@media screen and (max-width: map-get($grid-breakpoints, md)-0.02) {
.navbar {
width: 100vw;
background-color: var(--bs-white);
position: absolute;
overflow: hidden;
height: 0;
z-index: var(--ds-nav-z-index);
&.open {
height: 100vh; //doesn't matter because wrapper is sticky
border-bottom: 5px var(--ds-header-navbar-border-bottom-color) solid; // open navbar covers header-navbar-wrapper border
}
}
}
@media screen and (min-width: map-get($grid-breakpoints, md)) {
.reset-padding-md {
margin-left: calc(var(--bs-spacer) / -2);
margin-right: calc(var(--bs-spacer) / -2);
}
}
/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */
.navbar-expand-md.navbar-container {
@media screen and (max-width: map-get($grid-breakpoints, md)-0.02) {
> .navbar-inner-container {
padding: 0 var(--bs-spacer);
a.navbar-brand {
display: none;
}
.navbar-collapsed {
display: none;
}
}
padding: 0;
}
height: 80px;
}
a.navbar-brand img {
max-height: var(--ds-header-logo-height);
}
.navbar-nav ::ng-deep {
a.nav-link, .btn.nav-link {
color: var(--ds-navbar-link-color);
&:hover, &:focus {
color: var(--ds-navbar-link-color-hover);
}
}
}

View File

@@ -1,11 +1,29 @@
// Override or add CSS variables for your theme here
:root {
@include media-breakpoint-up(md) {
--ds-header-logo-height: 40px;
--ds-header-height: 80px;
}
@include media-breakpoint-down(sm) {
--ds-header-logo-height: 50px;
--ds-header-height: 90px;
}
--ds-banner-text-background: rgba(0, 0, 0, 0.45);
--ds-banner-background-gradient-width: 300px;
--ds-home-news-link-color: #{$green};
--ds-home-news-link-hover-color: #{darken($green, 15%)};
--ds-header-navbar-border-bottom-color: #{$green};
--ds-header-navbar-border-bottom-height: 5px;
/* set the next two properties as `--ds-header-navbar-border-bottom-*`
in order to keep the bottom border of the header when navbar is expanded */
--ds-mobile-navbar-border-top-color: #{$white};
--ds-mobile-navbar-border-top-height: 0;
--ds-mobile-navbar-padding-top: 0;
}