Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/app/layout/_header-action-button.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Shared styling for the global header action buttons on mobile and tablet
// (account, notifications, language and main menu).
//
// Applied on top of `mat-stroked-button`, which supplies the border and hover
// states. The explicit box is required because MDC stroked buttons default to
// 36px high and grow with their border.
@mixin header-action-button {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
min-width: 40px;
padding: 0;
margin: 0;

// The MDC outlined button draws no focus ring of its own, so keyboard users
// get no indicator without this. Matches the ring used by the header nav
// buttons in header.component.scss.
&:focus-visible {
box-shadow: inset 0 0 0 2px var(--orcid-color-ui-focus, #1098ff);
outline: none;
}

mat-icon {
width: 24px;
height: 24px;
font-size: 24px;
line-height: 24px;
overflow: visible;
margin: 0;
color: var(--orcid-color-ui-background-darkest, #212121);
}

.filled {
font-variation-settings: 'FILL' 1;
}
}
42 changes: 32 additions & 10 deletions src/app/layout/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,17 @@
</div>
</a>
<div class="col no-gutters" role="navigation">
<div *ngIf="!platform.columns12" class="row middle no-gutters">
<div *ngIf="mobileMenuState" class="col no-gutters">
<app-language
class="col no-gutters"
id="cy-language-mob"
></app-language>
</div>
<div *ngIf="!mobileMenuState" class="col no-gutters">
<app-user-menu></app-user-menu>
</div>
<div
*ngIf="!platform.columns12"
class="row middle no-gutters header-actions"
>
<app-user-menu
[hideSignedOutAccount]="isAuthPatternPage"
></app-user-menu>
<app-language id="cy-language-mob"></app-language>

<app-menu-icon
*ngIf="!hideMainMenu"
(click)="toggleMobileMenu()"
[isOpen]="mobileMenuState"
></app-menu-icon>
Expand Down Expand Up @@ -100,6 +99,29 @@
[ngClass]="{ container: platform.columns12 }"
tabindex="-1"
>
<ul
class="row mobile-auth-menu"
*ngIf="checkMenuItemRequirements({ desktop: false })"
>
<li
class="col no-gutters button-wrapper"
*ngIf="checkMenuItemRequirements({ logging: false })"
>
<button
[ngClass]="{ active: activeMenuItemId === 'header-signin' }"
class="top-menu-button sign-in-button"
mat-button
(click)="onNavClick('signin', 'header-signin')"
id="header-signin"
>
<ng-container i18n="@@header.signin">Sign in</ng-container
>&nbsp;/&nbsp;<ng-container i18n="@@header.register"
>Register</ng-container
>
</button>
</li>
</ul>

<ng-template #recursiveList let-list let-parents="parents">
<ul class="row" *ngIf="!isDesktopThirdLevelMenu(parents)">
<ng-container *ngFor="let item of list">
Expand Down
7 changes: 7 additions & 0 deletions src/app/layout/header/header.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ nav {
width: auto;
}
}
.header-actions {
flex-wrap: nowrap;
gap: 16px;
}
}
nav {
.top-bar,
Expand Down Expand Up @@ -250,6 +254,9 @@ nav {
flex-direction: column;
gap: 0;
width: 100%;
&.mobile-auth-menu button.top-menu-button {
font-weight: var(--orcid-font-weight-bold, 700);
}
.col {
flex: 0 0 auto;
max-width: 100%;
Expand Down
88 changes: 79 additions & 9 deletions src/app/layout/header/header.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,22 +71,92 @@ describe('HeaderComponent', () => {
expect(fixture.nativeElement.querySelector('app-search')).toBeFalsy()
})

it('renders public nav items without a sign-in/register action', () => {
function mobileNavButtonLabels(): string[] {
const navButtons = fixture.nativeElement.querySelectorAll(
'nav button'
) as NodeListOf<HTMLButtonElement>
return Array.from(navButtons).map((button: HTMLButtonElement) =>
button.textContent?.replace(/\s+/g, ' ').trim()
)
}

it('renders a sign-in/register action above the public nav items', () => {
component.platform = mobilePlatform
component.mobileMenuState = true
component.user = undefined
fixture.detectChanges()

const navButtons = fixture.nativeElement.querySelectorAll(
'nav button'
) as NodeListOf<HTMLButtonElement>
const buttonLabels = Array.from(navButtons).map(
(button: HTMLButtonElement) =>
button.textContent?.replace(/\s+/g, ' ').trim()
)
const buttonLabels = mobileNavButtonLabels()

expect(buttonLabels[0]).toBe('Sign in / Register')
expect(buttonLabels[1]).toBe('ABOUT')
})

it('hides the sign-in/register action when the user is signed in', () => {
component.platform = mobilePlatform
component.mobileMenuState = true
component.user = {} as any
fixture.detectChanges()

const buttonLabels = mobileNavButtonLabels()

expect(buttonLabels[0]).toBe('ABOUT')
expect(buttonLabels.some((label) => label?.includes('Sign in'))).toBe(false)
expect(buttonLabels[0]).toBe('ABOUT')
})

it('shows the main menu only on the homepage', () => {
component.updateRouteFlags('')
expect(component.hideMainMenu).toBe(false)

component.updateRouteFlags('/')
expect(component.hideMainMenu).toBe(false)

// Query strings and fragments must not be mistaken for a route
component.updateRouteFlags('/?lang=es')
expect(component.hideMainMenu).toBe(false)

component.updateRouteFlags('/signin')
expect(component.hideMainMenu).toBe(true)

component.updateRouteFlags('/0000-0001-5727-2427')
expect(component.hideMainMenu).toBe(true)
})

it('flags the sign-in pattern pages so the account action is hidden', () => {
component.updateRouteFlags('/signin')
expect(component.isAuthPatternPage).toBe(true)

component.updateRouteFlags('/register')
expect(component.isAuthPatternPage).toBe(true)

component.updateRouteFlags('/orcid-search/search?searchQuery=test')
expect(component.isAuthPatternPage).toBe(true)

component.updateRouteFlags('/')
expect(component.isAuthPatternPage).toBe(false)

component.updateRouteFlags('/0000-0001-5727-2427')
expect(component.isAuthPatternPage).toBe(false)
})

it('renders the menu icon only when the main menu is available', () => {
component.platform = mobilePlatform
component.hideMainMenu = false
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('app-menu-icon')).toBeTruthy()

component.hideMainMenu = true
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('app-menu-icon')).toBeFalsy()
})

it('keeps the account and language actions visible while the menu is open', () => {
component.platform = mobilePlatform
component.mobileMenuState = true
fixture.detectChanges()

expect(fixture.nativeElement.querySelector('app-user-menu')).toBeTruthy()
expect(fixture.nativeElement.querySelector('app-language')).toBeTruthy()
})

it('sets active menu item id when a navigable item is clicked', () => {
Expand Down
36 changes: 32 additions & 4 deletions src/app/layout/header/header.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ import { of, Observable } from 'rxjs'
import { HeaderCompactService } from 'src/app/core/header-compact/header-compact.service'
import { RecordHeaderStateService } from 'src/app/core/record-header-state/record-header-state.service'

/**
* Pages that follow the sign-in page pattern, where the account action is not
* offered in the header. Matched against the first path segment.
*/
const AUTH_PATTERN_ROUTES = [
ApplicationRoutes.signin,
ApplicationRoutes.register,
ApplicationRoutes.resetPassword,
ApplicationRoutes.myOrcid,
'members',
'orcid-search',
]

@Component({
selector: 'app-header',
templateUrl: './header.component.html',
Expand All @@ -38,6 +51,7 @@ import { RecordHeaderStateService } from 'src/app/core/record-header-state/recor
})
export class HeaderComponent implements OnInit, AfterViewInit, OnDestroy {
hideMainMenu = false
isAuthPatternPage = false
_currentRoute: string
notWordpressDisplay: boolean
/** Shown as .active immediately after click until navigation completes. */
Expand Down Expand Up @@ -118,10 +132,19 @@ export class HeaderComponent implements OnInit, AfterViewInit, OnDestroy {
(active) => (this.isCompactActive = active)
)

_router.events.subscribe(() => {
const path = location.path()
this.hideMainMenu = path.indexOf(`/${ApplicationRoutes.home}`) !== -1
})
_router.events.subscribe(() => this.updateRouteFlags(location.path()))
this.updateRouteFlags(location.path())
}

/**
* The main menu is only offered on the homepage, and the account action is
* hidden on the sign-in pattern pages.
*/
updateRouteFlags(rawPath: string) {
const path = rawPath.split('?')[0].split('#')[0]
this.hideMainMenu = !(path === '' || path === '/')
const firstSegment = path.split('/')[1] || ''
this.isAuthPatternPage = AUTH_PATTERN_ROUTES.includes(firstSegment)
}

ngOnInit() {}
Expand Down Expand Up @@ -358,6 +381,11 @@ export class HeaderComponent implements OnInit, AfterViewInit, OnDestroy {
: 'null'
}

onNavClick(route: string, menuItemId: string) {
this.activeMenuItemId = menuItemId
this.goto(route)
}

goto(route: string) {
if (route === 'signin') {
this._router.navigate([ApplicationRoutes.signin])
Expand Down
35 changes: 24 additions & 11 deletions src/app/layout/language/language.component.html
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
<button
mat-button
[matMenuTriggerFor]="languageMenu"
[attr.aria-label]="labelLanguage"
class="orcid-button-light-grey"
>
<div class="row middle space-between button-box no-wrap">
<p class="col no-gutters">{{ languageMenuOptions[locale] }}</p>
<mat-icon>expand_more</mat-icon>
</div>
</button>
<ng-container *ngIf="(platform$ | async)?.columns12; else languageIconButton">
<button
mat-button
[matMenuTriggerFor]="languageMenu"
[attr.aria-label]="labelLanguage"
class="orcid-button-light-grey"
>
<div class="row middle space-between button-box no-wrap">
<p class="col no-gutters">{{ languageMenuOptions[locale] }}</p>
<mat-icon>expand_more</mat-icon>
</div>
</button>
</ng-container>

<ng-template #languageIconButton>
<button
mat-stroked-button
class="header-action-button"
[matMenuTriggerFor]="languageMenu"
[attr.aria-label]="labelLanguage"
>
<mat-icon class="material-symbols-outlined">language</mat-icon>
</button>
</ng-template>

<mat-menu
#languageMenu="matMenu"
Expand Down
11 changes: 10 additions & 1 deletion src/app/layout/language/language.component.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@import '../header-action-button';

:host {
> button {
> button.orcid-button-light-grey {
padding: 0 7px 0 12px;
p {
margin: 0;
Expand All @@ -10,4 +12,11 @@
width: auto;
}
}

> button.header-action-button {
@include header-action-button;

height: 40px;
line-height: 40px;
}
}
29 changes: 29 additions & 0 deletions src/app/layout/language/language.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import { LanguageService } from '../../core/language/language.service'
import { MatMenuModule } from '@angular/material/menu'

import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { PlatformInfo } from '../../cdk/platform-info'

describe('LanguageComponent', () => {
let component: LanguageComponent
let fixture: ComponentFixture<LanguageComponent>
let platformSubject: BehaviorSubject<PlatformInfo>

beforeEach(() => {
;(globalThis as any).runtimeEnvironment = {
Expand All @@ -44,10 +47,36 @@ describe('LanguageComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(LanguageComponent)
component = fixture.componentInstance
// Drive the platform through a subject so the async pipe marks the OnPush
// view dirty on every emission, as it does in the running app.
platformSubject = new BehaviorSubject({ columns12: true } as PlatformInfo)
component.platform$ = platformSubject
fixture.detectChanges()
})

function setPlatform(platform: Partial<PlatformInfo>) {
platformSubject.next(platform as PlatformInfo)
fixture.detectChanges()
}

it('should create', () => {
expect(component).toBeTruthy()
})

it('renders the language name on desktop and a globe icon below it', () => {
expect(
fixture.nativeElement.querySelector('button.orcid-button-light-grey')
).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('English')

setPlatform({ columns12: false })

const iconButton = fixture.nativeElement.querySelector(
'button.header-action-button'
)
expect(iconButton).toBeTruthy()
expect(iconButton.querySelector('mat-icon').textContent.trim()).toBe(
'language'
)
})
})
Loading
Loading