Skip to content

Commit

Permalink
Merge pull request #1304 from hashicorp/app-sidenav-component
Browse files Browse the repository at this point in the history
`SideNav` component - Porting of advanced features from `HcNav`
  • Loading branch information
didoo committed Apr 20, 2023
2 parents 74af65b + 06ced98 commit 05c55ba
Show file tree
Hide file tree
Showing 45 changed files with 1,930 additions and 677 deletions.
5 changes: 5 additions & 0 deletions .changeset/moody-cougars-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashicorp/design-system-components": minor
---

Extended the `Hds::SideNav` component to support responsiveness (animation/transition) and content portaling by adapting existing implementation for Cloud UI
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import Component from '@glimmer/component';
import { assert } from '@ember/debug';

export default class HdsSideNavHomeLinkComponent extends Component {
export default class HdsSideNavHeaderHomeLinkComponent extends Component {
/**
* @param ariaLabel
* @type {string}
Expand All @@ -16,7 +16,7 @@ export default class HdsSideNavHomeLinkComponent extends Component {
let { ariaLabel } = this.args;

assert(
'@ariaLabel for "Hds::SideNav::HomeLink" must have a valid value',
'@ariaLabel for "Hds::SideNav::Header::HomeLink" ("Logo") must have a valid value',
ariaLabel !== undefined
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import Component from '@glimmer/component';
import { assert } from '@ember/debug';

export default class HdsSideNavIconButtonComponent extends Component {
export default class HdsSideNavHeaderIconButtonComponent extends Component {
/**
* @param ariaLabel
* @type {string}
Expand All @@ -16,7 +16,7 @@ export default class HdsSideNavIconButtonComponent extends Component {
let { ariaLabel } = this.args;

assert(
'@ariaLabel for "Hds::SideNav::IconButton" must have a valid value',
'@ariaLabel for "Hds::SideNav::Header::IconButton" must have a valid value',
ariaLabel !== undefined
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
}}

<div class="hds-side-nav-header" ...attributes>
<div class="hds-side-nav-header__logo">
<div class="hds-side-nav-header__logo-container">
{{yield to="logo"}}
</div>
<div class="hds-side-nav-header__actions">
<div class="hds-side-nav-header__actions-container hds-side-nav-hide-when-minimized">
{{yield to="actions"}}
</div>
</div>
38 changes: 38 additions & 0 deletions packages/components/addon/components/hds/side-nav/index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<div class={{this.classNames}} ...attributes {{focus-trap isActive=this.shouldTrapFocus}}>
{{#if this.hasA11yRefocus}}
<NavigationNarrator
@routeChangeValidator={{@a11yRefocusRouteChangeValidator}}
@skipTo="#{{@a11yRefocusSkipTo}}"
@skipText={{@a11yRefocusSkipText}}
@navigationText={{@a11yRefocusNavigationText}}
/>
{{/if}}

{{#if this.isResponsive}}
{{! template-lint-disable no-invalid-interactive}}
<div class="hds-side-nav__overlay" {{on "click" this.toggleMinimizedStatus}} />
{{/if}}

<div class="hds-side-nav__wrapper">
{{#if this.isResponsive}}
<button
class="hds-side-nav__menu-toggle-button"
type="button"
{{on "click" this.toggleMinimizedStatus}}
{{! To be localized - see: https://hashicorp.atlassian.net/browse/HDS-567 }}
aria-label={{if this.isMinimized "Open menu" "Close menu"}}
>
<FlightIcon @name={{if this.isMinimized "menu" "x"}} @size="24" @stretched={{true}} />
</button>
{{/if}}
<div class="hds-side-nav__wrapper-header">
{{yield (hash isMinimized=this.isMinimized) to="header"}}
</div>
<div class="hds-side-nav__wrapper-body">
{{yield (hash isMinimized=this.isMinimized) to="body"}}
</div>
<div class="hds-side-nav__wrapper-footer">
{{yield (hash isMinimized=this.isMinimized) to="footer"}}
</div>
</div>
</div>
107 changes: 107 additions & 0 deletions packages/components/addon/components/hds/side-nav/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { assert } from '@ember/debug';
import { registerDestructor } from '@ember/destroyable';

export default class HdsSideNavComponent extends Component {
@tracked isResponsive = this.args.isResponsive ?? true;
@tracked isMinimized = this.isResponsive; // we set it minimized by default so that if we switch viewport from desktop to mobile its already minimized
@tracked isDesktop = true;
hasA11yRefocus = this.args.hasA11yRefocus ?? true;

desktopMQVal = getComputedStyle(document.documentElement).getPropertyValue(
'--hds-app-desktop-breakpoint'
);

constructor() {
super(...arguments);
this.desktopMQ = window.matchMedia(`(min-width:${this.desktopMQVal})`);
this.addEventListeners();
registerDestructor(this, () => {
this.removeEventListeners();
});

if (this.args.hasA11yRefocus) {
assert(
'@a11yRefocusSkipTo for NavigatorNarrator (a11y-refocus) in "Hds::SideNav" must have a valid value',
this.args.a11yRefocusSkipTo !== undefined
);
}
}

addEventListeners() {
document.addEventListener('keydown', this.escapePress, true);
this.desktopMQ.addEventListener('change', this.updateDesktopVariable, true);
// set initial state based on viewport
this.updateDesktopVariable({ matches: this.desktopMQ.matches });
}

removeEventListeners() {
document.removeEventListener('keydown', this.escapePress, true);
this.desktopMQ.removeEventListener(
'change',
this.updateDesktopVariable,
true
);
}

get shouldTrapFocus() {
return this.isResponsive && !this.isDesktop && !this.isMinimized;
}

get classNames() {
let classes = ['hds-side-nav'];

// add specific class names for the different possible states
if (this.isDesktop) {
classes.push('hds-side-nav--is-desktop');
} else {
classes.push('hds-side-nav--is-mobile');
}
if (this.isResponsive) {
classes.push('hds-side-nav--is-responsive');
}
if (this.isMinimized) {
classes.push('hds-side-nav--is-minimized');
} else {
classes.push('hds-side-nav--is-not-minimized');
}

return classes.join(' ');
}

@action
escapePress(event) {
if (event.key === 'Escape' && !this.isMinimized) {
this.isMinimized = true;
}
}

@action
toggleMinimizedStatus() {
this.isMinimized = !this.isMinimized;

let { onToggleMinimizedStatus } = this.args;

if (typeof onToggleMinimizedStatus === 'function') {
onToggleMinimizedStatus(this.isMinimized);
}
}

@action
updateDesktopVariable(event) {
this.isDesktop = event.matches;

let { onDesktopViewportChange } = this.args;

if (typeof onDesktopViewportChange === 'function') {
onDesktopViewportChange(this.isDesktop);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
}}

<nav class="hds-side-nav__list-wrapper" ...attributes>
{{yield (hash extraBefore=(component "hds/yield"))}}
<ul class="hds-side-nav__list" role="list">
{{yield
(hash
Expand All @@ -14,4 +15,5 @@
)
}}
</ul>
{{yield (hash extraAfter=(component "hds/yield"))}}
</nav>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Portal @target={{if @targetName @targetName "hds-side-nav-portal-target"}}>
<div class="hds-side-nav__content-panel" ...attributes>
<Hds::SideNav::List aria-label={{@ariaLabel}} as |ListElements|>
{{yield ListElements}}
</Hds::SideNav::List>
</div>
</Portal>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div class="hds-side-nav__content" ...attributes>
<PortalTarget
@multiple={{true}}
@onChange={{this.panelsChanged}}
@name={{if @targetName @targetName "hds-side-nav-portal-target"}}
class="hds-side-nav__content-panels hds-side-nav-hide-when-minimized"
{{did-update this.didUpdateSubnav this.numSubnavs}}
/>
</div>
151 changes: 151 additions & 0 deletions packages/components/addon/components/hds/side-nav/portal/target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { DEBUG } from '@glimmer/env';
import Ember from 'ember';
import { debounce } from '@ember/runloop';

export default class SidenavPortalTarget extends Component {
@service router;

@tracked numSubnavs = 0;

static get prefersReducedMotionOverride() {
return Ember.testing;
}

prefersReducedMotionMQ = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);

get prefersReducedMotion() {
return (
this.constructor.prefersReducedMotionOverride ||
(this.prefersReducedMotionMQ && this.prefersReducedMotionMQ.matches)
);
}

@action
panelsChanged(portalCount) {
this.numSubnavs = portalCount;
}

@action
didUpdateSubnav(element, [count]) {
debounce(this, 'animateSubnav', element, [count], 100);
}

@action
animateSubnav(element, [count]) {
/*
* Here is what the layout looks like for this setup
*
SideNav
+----------------------+
| +------------------+ |
| | ("header") | |
| +------------------+ |
| |
| +------------------+ |
| | ("body") | |
(PortalTarget) | | | |
+----------------------------------------------+ | |
| +----------+ +----------+ | +----------+ | | |
| | (Portal) | | (Portal) | | (Portal) | | | |
| | | | | | | | | | |
| | hidden | | hidden | | *active* | | | |
| | panel | | panel | | | panel | | | |
| | | | | | | | | |
| | | | | | | | | | |
| | | | | | | | | |
| | | | | | | | | | |
| | | | | | | | | |
| | | | | | | | | | |
| | | | | | | | | |
| +----------+ +----------+ | +----------+ | | |
+----------------------------------------------+ | |
| | | |
| +------------------+ |
| |
| +------------------+ |
| | ("footer") | |
| +------------------+ |
+----------------------+
*
* every time `HcAppFrame::SideNav::Portal` renders, it contains a portaled "panel"
* that is rendered into the `hds-side-nav__content-panels` (inside the PortalTarget).
*
* Rendering or unrendering other `HcAppFrame::SideNav::Portal`s triggers the number of
* subnavs to change (via `numSubnavs`), so this function runs and slides
* `hds-side-nav__content-panels` left or right using the `element.animate` api.
*
* */

let activeIndex = count - 1;
let targetElement = element;
let { prefersReducedMotion } = this;

let styles = getComputedStyle(targetElement);
let columnWidth = styles.getPropertyValue(
'--hds-app-sidenav-width-expanded'
);
let slideDuration = prefersReducedMotion ? 0 : 150;
let fadeDuration = prefersReducedMotion ? 0 : 175;
let fadeDelay = prefersReducedMotion ? 0 : 50;

// slide entire parent panel
let start = styles.transform;
let end = `translateX(-${activeIndex * parseInt(columnWidth, 10)}px)`;
let anim = targetElement.animate(
[{ transform: start }, { transform: end }],
{
duration: slideDuration,
easing: 'cubic-bezier(0.65, 0, 0.35, 1)',
fill: 'forwards',
}
);
// Notice: we don't add the styles by default because it writes a `style` attribute to the element and it causes an additional re-render
if (DEBUG) {
anim.commitStyles();
}
anim.finished.then(() => {
// uncomment this if we need/want to scroll the element to the top
// targetElement.scrollIntoView(true);
if (activeIndex > 0) {
let allPrev = Array.from(targetElement.children).slice(0, activeIndex);
for (let ele of allPrev) {
ele.ariaHidden = 'true';
ele.style.setProperty('visibility', 'hidden');
ele.style.setProperty('opacity', '0');
}
}
// Notice: we don't add the styles by default because it writes a `style` attribute to the element and it causes an additional re-render
if (DEBUG) {
// Check the visibility of the element before attempting to commitStyles.
if (targetElement.offsetParent !== null) {
anim.commitStyles();
}
}
});

// fade in next panel
let nextPanelEl = targetElement.children[activeIndex];
if (nextPanelEl) {
nextPanelEl.ariaHidden = 'false';
nextPanelEl.style.setProperty('visibility', 'visible');
// this eliminates a flicker if there's only 1 subnav rendering
if (activeIndex === 0) {
fadeDelay = 0;
fadeDuration = 0;
}
nextPanelEl.animate([{ opacity: '0' }, { opacity: '1' }], {
delay: fadeDelay,
duration: fadeDuration,
fill: 'forwards',
});
}
}
}
Loading

2 comments on commit 05c55ba

@vercel
Copy link

@vercel vercel bot commented on 05c55ba Apr 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

hds-showcase – ./packages/components

hds-showcase.vercel.app
hds-showcase-git-main-hashicorp.vercel.app
hds-showcase-hashicorp.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 05c55ba Apr 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.