From 42b5265944ffd79651914b10dcbf9e4746b0b510 Mon Sep 17 00:00:00 2001 From: Kristin Bradley Date: Tue, 3 Sep 2024 15:46:20 -0700 Subject: [PATCH 01/71] HDS-3800 Add AppSideNav component initial files --- packages/components/package.json | 13 + .../src/components/hds/app-side-nav/base.hbs | 19 + .../src/components/hds/app-side-nav/base.ts | 25 + .../hds/app-side-nav/header/home-link.hbs | 20 + .../hds/app-side-nav/header/home-link.ts | 37 + .../hds/app-side-nav/header/icon-button.hbs | 20 + .../hds/app-side-nav/header/icon-button.ts | 36 + .../hds/app-side-nav/header/index.hbs | 13 + .../hds/app-side-nav/header/index.ts | 19 + .../src/components/hds/app-side-nav/index.hbs | 47 + .../src/components/hds/app-side-nav/index.ts | 210 ++++ .../hds/app-side-nav/list/back-link.hbs | 24 + .../hds/app-side-nav/list/back-link.ts | 20 + .../hds/app-side-nav/list/index.hbs | 19 + .../components/hds/app-side-nav/list/index.ts | 34 + .../components/hds/app-side-nav/list/item.hbs | 8 + .../components/hds/app-side-nav/list/item.ts | 18 + .../components/hds/app-side-nav/list/link.hbs | 50 + .../components/hds/app-side-nav/list/link.ts | 29 + .../hds/app-side-nav/list/title.hbs | 11 + .../components/hds/app-side-nav/list/title.ts | 18 + .../hds/app-side-nav/portal/index.hbs | 12 + .../hds/app-side-nav/portal/index.ts | 32 + .../hds/app-side-nav/portal/target.hbs | 14 + .../hds/app-side-nav/portal/target.ts | 193 ++++ .../hds/app-side-nav/toggle-button.hbs | 7 + .../hds/app-side-nav/toggle-button.ts | 20 + .../@hashicorp/design-system-components.scss | 1 + .../components/app-side-nav/a11y-refocus.scss | 30 + .../components/app-side-nav/content.scss | 162 ++++ .../components/app-side-nav/header.scss | 124 +++ .../styles/components/app-side-nav/index.scss | 11 + .../styles/components/app-side-nav/main.scss | 190 ++++ .../app-side-nav/toggle-button.scss | 111 +++ .../styles/components/app-side-nav/vars.scss | 36 + packages/components/src/template-registry.ts | 68 ++ .../src/products/shared/app-side-nav.json | 137 +++ showcase/app/router.ts | 1 + .../app/routes/components/app-side-nav.js | 8 + .../app/styles/showcase-pages/app-frame.scss | 38 +- .../styles/showcase-pages/app-side-nav.scss | 82 ++ .../app/templates/components/app-side-nav.hbs | 895 ++++++++++++++++++ showcase/app/templates/index.hbs | 5 + .../components/hds/app-side-nav-test.js | 33 + .../components/hds/app-side-nav/base-test.js | 43 + .../hds/app-side-nav/header/home-link-test.js | 66 ++ .../app-side-nav/header/icon-button-test.js | 82 ++ .../hds/app-side-nav/header/index-test.js | 35 + .../components/hds/app-side-nav/index-test.js | 268 ++++++ .../hds/app-side-nav/list/back-link-test.js | 68 ++ .../hds/app-side-nav/list/index-test.js | 52 + .../hds/app-side-nav/list/item-test.js | 38 + .../hds/app-side-nav/list/link-test.js | 84 ++ .../hds/app-side-nav/portal/index-test.js | 149 +++ .../components/app-side-nav-test.js | 33 + 55 files changed, 3808 insertions(+), 10 deletions(-) create mode 100644 packages/components/src/components/hds/app-side-nav/base.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/base.ts create mode 100644 packages/components/src/components/hds/app-side-nav/header/home-link.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/header/home-link.ts create mode 100644 packages/components/src/components/hds/app-side-nav/header/icon-button.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/header/icon-button.ts create mode 100644 packages/components/src/components/hds/app-side-nav/header/index.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/header/index.ts create mode 100644 packages/components/src/components/hds/app-side-nav/index.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/index.ts create mode 100644 packages/components/src/components/hds/app-side-nav/list/back-link.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/list/back-link.ts create mode 100644 packages/components/src/components/hds/app-side-nav/list/index.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/list/index.ts create mode 100644 packages/components/src/components/hds/app-side-nav/list/item.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/list/item.ts create mode 100644 packages/components/src/components/hds/app-side-nav/list/link.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/list/link.ts create mode 100644 packages/components/src/components/hds/app-side-nav/list/title.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/list/title.ts create mode 100644 packages/components/src/components/hds/app-side-nav/portal/index.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/portal/index.ts create mode 100644 packages/components/src/components/hds/app-side-nav/portal/target.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/portal/target.ts create mode 100644 packages/components/src/components/hds/app-side-nav/toggle-button.hbs create mode 100644 packages/components/src/components/hds/app-side-nav/toggle-button.ts create mode 100644 packages/components/src/styles/components/app-side-nav/a11y-refocus.scss create mode 100644 packages/components/src/styles/components/app-side-nav/content.scss create mode 100644 packages/components/src/styles/components/app-side-nav/header.scss create mode 100644 packages/components/src/styles/components/app-side-nav/index.scss create mode 100644 packages/components/src/styles/components/app-side-nav/main.scss create mode 100644 packages/components/src/styles/components/app-side-nav/toggle-button.scss create mode 100644 packages/components/src/styles/components/app-side-nav/vars.scss create mode 100644 packages/tokens/src/products/shared/app-side-nav.json create mode 100644 showcase/app/routes/components/app-side-nav.js create mode 100644 showcase/app/styles/showcase-pages/app-side-nav.scss create mode 100644 showcase/app/templates/components/app-side-nav.hbs create mode 100644 showcase/tests/acceptance/components/hds/app-side-nav-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/base-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/header/home-link-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/header/icon-button-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/header/index-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/index-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/list/back-link-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/list/index-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/list/item-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/list/link-test.js create mode 100644 showcase/tests/integration/components/hds/app-side-nav/portal/index-test.js create mode 100644 website/tests/acceptance/components/app-side-nav-test.js diff --git a/packages/components/package.json b/packages/components/package.json index 5797ebcbdb..8b0ea31494 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -139,6 +139,19 @@ "./components/hds/app-header/home-link.js": "./dist/_app_/components/hds/app-header/home-link.js", "./components/hds/app-header/index.js": "./dist/_app_/components/hds/app-header/index.js", "./components/hds/app-header/menu-button.js": "./dist/_app_/components/hds/app-header/menu-button.js", + "./components/hds/app-side-nav/base.js": "./dist/_app_/components/hds/app-side-nav/base.js", + "./components/hds/app-side-nav/header/home-link.js": "./dist/_app_/components/hds/app-side-nav/header/home-link.js", + "./components/hds/app-side-nav/header/icon-button.js": "./dist/_app_/components/hds/app-side-nav/header/icon-button.js", + "./components/hds/app-side-nav/header/index.js": "./dist/_app_/components/hds/app-side-nav/header/index.js", + "./components/hds/app-side-nav/index.js": "./dist/_app_/components/hds/app-side-nav/index.js", + "./components/hds/app-side-nav/list/back-link.js": "./dist/_app_/components/hds/app-side-nav/list/back-link.js", + "./components/hds/app-side-nav/list/index.js": "./dist/_app_/components/hds/app-side-nav/list/index.js", + "./components/hds/app-side-nav/list/item.js": "./dist/_app_/components/hds/app-side-nav/list/item.js", + "./components/hds/app-side-nav/list/link.js": "./dist/_app_/components/hds/app-side-nav/list/link.js", + "./components/hds/app-side-nav/list/title.js": "./dist/_app_/components/hds/app-side-nav/list/title.js", + "./components/hds/app-side-nav/portal/index.js": "./dist/_app_/components/hds/app-side-nav/portal/index.js", + "./components/hds/app-side-nav/portal/target.js": "./dist/_app_/components/hds/app-side-nav/portal/target.js", + "./components/hds/app-side-nav/toggle-button.js": "./dist/_app_/components/hds/app-side-nav/toggle-button.js", "./components/hds/application-state/body.js": "./dist/_app_/components/hds/application-state/body.js", "./components/hds/application-state/footer.js": "./dist/_app_/components/hds/application-state/footer.js", "./components/hds/application-state/header.js": "./dist/_app_/components/hds/application-state/header.js", diff --git a/packages/components/src/components/hds/app-side-nav/base.hbs b/packages/components/src/components/hds/app-side-nav/base.hbs new file mode 100644 index 0000000000..824387a163 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/base.hbs @@ -0,0 +1,19 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +{{! IMPORTANT: we need to add "squishies" here (~) because otherwise the whitespace added by Ember causes the empty element to still have visible padding - See https://handlebarsjs.com/guide/expressions.html#whitespace-control }} +
+
+ {{yield to="root"}} +
+ {{~yield to="header"~}} +
+
+ {{~yield to="body"~}} +
+ +
+
\ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/base.ts b/packages/components/src/components/hds/app-side-nav/base.ts new file mode 100644 index 0000000000..8fb6d44f23 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/base.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +export interface HdsAppSideNavBaseSignature { + Blocks: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + root?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + header?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + footer?: any; + }; + Element: HTMLDivElement; +} + +const HdsAppSideNavBaseComponent = + TemplateOnlyComponent(); + +export default HdsAppSideNavBaseComponent; diff --git a/packages/components/src/components/hds/app-side-nav/header/home-link.hbs b/packages/components/src/components/hds/app-side-nav/header/home-link.hbs new file mode 100644 index 0000000000..4c66300d35 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/header/home-link.hbs @@ -0,0 +1,20 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/header/home-link.ts b/packages/components/src/components/hds/app-side-nav/header/home-link.ts new file mode 100644 index 0000000000..57da421ded --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/header/home-link.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { assert } from '@ember/debug'; + +import type { HdsIconSignature } from '../../icon'; +import type { HdsInteractiveSignature } from '../../interactive'; + +interface HdsAppSideNavHeaderHomeLinkSignature { + Args: HdsInteractiveSignature['Args'] & { + icon: HdsIconSignature['Args']['name']; + color?: string; + ariaLabel: string; + }; + Element: HdsInteractiveSignature['Element']; +} + +export default class HdsAppSideNavHeaderHomeLinkComponent extends Component { + /** + * @param ariaLabel + * @type {string} + * @description The value of `aria-label` + */ + get ariaLabel(): string { + const { ariaLabel } = this.args; + + assert( + '@ariaLabel for "Hds::AppSideNav::Header::HomeLink" ("Logo") must have a valid value', + ariaLabel !== undefined + ); + + return ariaLabel; + } +} diff --git a/packages/components/src/components/hds/app-side-nav/header/icon-button.hbs b/packages/components/src/components/hds/app-side-nav/header/icon-button.hbs new file mode 100644 index 0000000000..7fde2c70cb --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/header/icon-button.hbs @@ -0,0 +1,20 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/header/icon-button.ts b/packages/components/src/components/hds/app-side-nav/header/icon-button.ts new file mode 100644 index 0000000000..d53aedc571 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/header/icon-button.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { assert } from '@ember/debug'; + +import type { HdsIconSignature } from '../../icon'; +import type { HdsInteractiveSignature } from '../../interactive'; + +interface HdsAppSideNavHeaderIconButtonSignature { + Args: HdsInteractiveSignature['Args'] & { + icon: HdsIconSignature['Args']['name']; + ariaLabel: string; + }; + Element: HdsInteractiveSignature['Element']; +} + +export default class HdsAppSideNavHeaderIconButtonComponent extends Component { + /** + * @param ariaLabel + * @type {string} + * @description The value of `aria-label` + */ + get ariaLabel(): string { + const { ariaLabel } = this.args; + + assert( + '@ariaLabel for "Hds::AppSideNav::Header::IconButton" must have a valid value', + ariaLabel !== undefined + ); + + return ariaLabel; + } +} diff --git a/packages/components/src/components/hds/app-side-nav/header/index.hbs b/packages/components/src/components/hds/app-side-nav/header/index.hbs new file mode 100644 index 0000000000..16d3f191c4 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/header/index.hbs @@ -0,0 +1,13 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +{{! IMPORTANT: we need to add "squishies" here (~) because otherwise the whitespace added by Ember causes the empty element to still have visible padding - See https://handlebarsjs.com/guide/expressions.html#whitespace-control }} +
+
+ {{~yield to="logo"~}} +
+
+ {{~yield to="actions"~}} +
+
\ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/header/index.ts b/packages/components/src/components/hds/app-side-nav/header/index.ts new file mode 100644 index 0000000000..f77f3e06a0 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/header/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +interface HdsAppSideNavHeaderSignature { + Blocks: { + logo?: []; + actions?: []; + }; + Element: HTMLDivElement; +} + +const HdsAppSideNavHeaderComponent = + TemplateOnlyComponent(); + +export default HdsAppSideNavHeaderComponent; diff --git a/packages/components/src/components/hds/app-side-nav/index.hbs b/packages/components/src/components/hds/app-side-nav/index.hbs new file mode 100644 index 0000000000..0bb35420b4 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/index.hbs @@ -0,0 +1,47 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +{{! IMPORTANT: we need to add "squishies" here (~) because otherwise the whitespace added by Ember causes the empty element to still have visible padding - See https://handlebarsjs.com/guide/expressions.html#whitespace-control }} + + + <:root> + {{#if this.hasA11yRefocus}} + {{! @glint-expect-error - `ember-a11y-refocus` doesn't expose types yet }} + + {{/if}} + {{#if this.showToggleButton}} + {{! template-lint-disable no-invalid-interactive}} +
+ {{! template-lint-enable no-invalid-interactive}} + + {{/if}} + + <:header as |Header|> + {{~yield (hash Header=Header isMinimized=this.isMinimized) to="header"~}} + + <:body as |Body|> + {{~yield (hash Body=Body isMinimized=this.isMinimized) to="body"~}} + + <:footer as |Footer|> + {{~yield (hash Footer=Footer isMinimized=this.isMinimized) to="footer"~}} + + \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/index.ts b/packages/components/src/components/hds/app-side-nav/index.ts new file mode 100644 index 0000000000..cc78cedd36 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/index.ts @@ -0,0 +1,210 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { assert } from '@ember/debug'; +import { registerDestructor } from '@ember/destroyable'; + +import type { HdsAppSideNavBaseSignature } from './base'; + +interface HdsAppSideNavSignature { + Args: { + isResponsive?: boolean; + isCollapsible?: boolean; + isMinimized?: boolean; + hasA11yRefocus?: boolean; + a11yRefocusSkipTo?: string; + a11yRefocusSkipText?: string; + a11yRefocusNavigationText?: string; + a11yRefocusRouteChangeValidator?: string; + a11yRefocusExcludeAllQueryParams?: boolean; + ariaLabel?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onToggleMinimizedStatus?: (arg: boolean) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onDesktopViewportChange?: (arg: boolean) => void; + }; + Blocks: { + header?: [ + { + Header?: HdsAppSideNavBaseSignature['Blocks']['header']; + isMinimized?: boolean; + }, + ]; + body?: [ + { + Body?: HdsAppSideNavBaseSignature['Blocks']['body']; + isMinimized?: boolean; + }, + ]; + footer?: [ + { + Footer?: HdsAppSideNavBaseSignature['Blocks']['footer']; + isMinimized?: boolean; + }, + ]; + }; + Element: HdsAppSideNavBaseSignature['Element']; +} + +export default class HdsAppSideNavComponent extends Component { + @tracked isResponsive = this.args.isResponsive ?? true; // controls if the component reacts to viewport changes + @tracked isMinimized = this.args.isMinimized ?? false; // sets the default state on 'desktop' viewports + @tracked isCollapsible = this.args.isCollapsible ?? false; // controls if users can collapse the sidenav on 'desktop' viewports + @tracked isAnimating = false; + @tracked isDesktop = true; + desktopMQ: MediaQueryList; + containersToHide!: NodeListOf; + hasA11yRefocus = this.args.hasA11yRefocus ?? true; + + desktopMQVal = getComputedStyle(document.documentElement).getPropertyValue( + '--hds-app-desktop-breakpoint' + ); + + constructor(owner: unknown, args: HdsAppSideNavSignature['Args']) { + super(owner, args); + this.desktopMQ = window.matchMedia(`(min-width:${this.desktopMQVal})`); + this.addEventListeners(); + registerDestructor(this, (): void => { + this.removeEventListeners(); + }); + + if (this.args.hasA11yRefocus) { + assert( + '@a11yRefocusSkipTo for NavigatorNarrator (a11y-refocus) in "Hds::AppSideNav" must have a valid value', + this.args.a11yRefocusSkipTo !== undefined + ); + } + } + + addEventListeners(): void { + document.addEventListener('keydown', this.escapePress, true); + this.desktopMQ.addEventListener('change', this.updateDesktopVariable, true); + // if not instantiated as minimized via arguments + if (!this.args.isMinimized) { + // set initial state based on viewport using a "synthetic" event + const syntheticEvent = new MediaQueryListEvent('change', { + matches: this.desktopMQ.matches, + media: this.desktopMQ.media, + }); + this.updateDesktopVariable(syntheticEvent); + } + } + + removeEventListeners(): void { + document.removeEventListener('keydown', this.escapePress, true); + this.desktopMQ.removeEventListener( + 'change', + this.updateDesktopVariable, + true + ); + } + + get shouldTrapFocus(): boolean { + return this.isResponsive && !this.isDesktop && !this.isMinimized; + } + + get showToggleButton(): boolean { + return (this.isResponsive && !this.isDesktop) || this.isCollapsible; + } + + /** + * @param ariaLabel + * @type {string} + * @default 'close menu' + */ + get ariaLabel(): string { + if (this.isMinimized) { + return this.args.ariaLabel ?? 'Open menu'; + } + return this.args.ariaLabel ?? 'Close menu'; + } + + get classNames(): string { + const classes = []; // `hds-app-side-nav` is already set by the "Hds::AppSideNav::Base" component + + // add specific class names for the different possible states + if (this.isResponsive) { + classes.push('hds-app-side-nav--is-responsive'); + } + if (!this.isDesktop && this.isResponsive) { + classes.push('hds-app-side-nav--is-mobile'); + } else { + classes.push('hds-app-side-nav--is-desktop'); + } + if (this.isMinimized && this.isResponsive) { + classes.push('hds-app-side-nav--is-minimized'); + } else { + classes.push('hds-app-side-nav--is-not-minimized'); + } + if (this.isAnimating) { + classes.push('hds-app-side-nav--is-animating'); + } + + return classes.join(' '); + } + + @action + escapePress(event: KeyboardEvent): void { + if (event.key === 'Escape' && !this.isMinimized && !this.isDesktop) { + this.isMinimized = true; + } + } + + @action + toggleMinimizedStatus(): void { + this.isMinimized = !this.isMinimized; + + this.containersToHide.forEach((element): void => { + if (this.isMinimized) { + element.setAttribute('inert', ''); + } else { + element.removeAttribute('inert'); + } + }); + + const { onToggleMinimizedStatus } = this.args; + + if (typeof onToggleMinimizedStatus === 'function') { + onToggleMinimizedStatus(this.isMinimized); + } + } + + @action + didInsert(element: HTMLElement): void { + this.containersToHide = element.querySelectorAll( + '.hds-app-side-nav-hide-when-minimized' + ); + } + + @action + setTransition(phase: string, event: TransitionEvent): void { + // we only want to respond to `width` animation/transitions + if (event.propertyName !== 'width') { + return; + } + if (phase === 'start') { + this.isAnimating = true; + } else { + this.isAnimating = false; + } + } + + @action + updateDesktopVariable(event: MediaQueryListEvent): void { + this.isDesktop = event.matches; + + // automatically minimize on narrow viewports (when not in desktop mode) + this.isMinimized = !this.isDesktop; + + const { onDesktopViewportChange } = this.args; + + if (typeof onDesktopViewportChange === 'function') { + onDesktopViewportChange(this.isDesktop); + } + } +} diff --git a/packages/components/src/components/hds/app-side-nav/list/back-link.hbs b/packages/components/src/components/hds/app-side-nav/list/back-link.hbs new file mode 100644 index 0000000000..91da72b719 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/back-link.hbs @@ -0,0 +1,24 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + + + {{@text}} + + + \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/list/back-link.ts b/packages/components/src/components/hds/app-side-nav/list/back-link.ts new file mode 100644 index 0000000000..6c80ee2097 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/back-link.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +import type { HdsInteractiveSignature } from '../../interactive'; + +export interface HdsAppSideNavListBackLinkSignature { + Args: HdsInteractiveSignature['Args'] & { + text: string; + }; + Element: HdsInteractiveSignature['Element']; +} + +const HdsAppSideNavListBackLinkComponent = + TemplateOnlyComponent(); + +export default HdsAppSideNavListBackLinkComponent; diff --git a/packages/components/src/components/hds/app-side-nav/list/index.hbs b/packages/components/src/components/hds/app-side-nav/list/index.hbs new file mode 100644 index 0000000000..2090ce8849 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/index.hbs @@ -0,0 +1,19 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/list/index.ts b/packages/components/src/components/hds/app-side-nav/list/index.ts new file mode 100644 index 0000000000..404bddc2b8 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/index.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +import type { ComponentLike } from '@glint/template'; +import type { HdsYieldSignature } from '../../yield'; +import type { HdsAppSideNavListItemSignature } from './item'; +import type { HdsAppSideNavListBackLinkSignature } from './back-link'; +import type { HdsAppSideNavListTitleSignature } from './title'; +import type { HdsAppSideNavListLinkSignature } from './link'; + +export interface HdsAppSideNavListSignature { + Blocks: { + default: [ + { + ExtraBefore?: ComponentLike; + Item?: ComponentLike; + BackLink?: ComponentLike; + Title?: ComponentLike; + Link?: ComponentLike; + ExtraAfter?: ComponentLike; + }, + ]; + }; + Element: HTMLElement; +} + +const HdsAppSideNavListComponent = + TemplateOnlyComponent(); + +export default HdsAppSideNavListComponent; diff --git a/packages/components/src/components/hds/app-side-nav/list/item.hbs b/packages/components/src/components/hds/app-side-nav/list/item.hbs new file mode 100644 index 0000000000..1a7773a5d3 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/item.hbs @@ -0,0 +1,8 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +
  • + {{yield}} +
  • \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/list/item.ts b/packages/components/src/components/hds/app-side-nav/list/item.ts new file mode 100644 index 0000000000..ee2b1461dc --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/item.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +export interface HdsAppSideNavListItemSignature { + Blocks: { + default: []; + }; + Element: HTMLLIElement; +} + +const HdsAppSideNavListItemComponent = + TemplateOnlyComponent(); + +export default HdsAppSideNavListItemComponent; diff --git a/packages/components/src/components/hds/app-side-nav/list/link.hbs b/packages/components/src/components/hds/app-side-nav/list/link.hbs new file mode 100644 index 0000000000..1d32bfd07f --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/link.hbs @@ -0,0 +1,50 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + {{#if @icon}} + + {{/if}} + + {{#if @text}} + + {{@text}} + + {{/if}} + + {{#if @count}} + + {{/if}} + + {{#if @badge}} + + {{/if}} + + {{yield}} + + {{#if @hasSubItems}} + + + + {{/if}} + {{#if @isHrefExternal}} + + + + {{/if}} + + \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/list/link.ts b/packages/components/src/components/hds/app-side-nav/list/link.ts new file mode 100644 index 0000000000..7da96d33a8 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/link.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +import type { HdsIconSignature } from '../../icon'; +import type { HdsInteractiveSignature } from '../../interactive'; + +export interface HdsAppSideNavListLinkSignature { + Args: HdsInteractiveSignature['Args'] & { + icon?: HdsIconSignature['Args']['name']; + text?: string; + badge?: string; + count?: string; + hasSubItems?: boolean; + isActive?: boolean; + }; + Blocks: { + default: []; + }; + Element: HdsInteractiveSignature['Element']; +} + +const HdsAppSideNavListLinkComponent = + TemplateOnlyComponent(); + +export default HdsAppSideNavListLinkComponent; diff --git a/packages/components/src/components/hds/app-side-nav/list/title.hbs b/packages/components/src/components/hds/app-side-nav/list/title.hbs new file mode 100644 index 0000000000..bfbf811aa9 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/title.hbs @@ -0,0 +1,11 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + +
    {{~yield~}}
    +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/list/title.ts b/packages/components/src/components/hds/app-side-nav/list/title.ts new file mode 100644 index 0000000000..766c355e20 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/title.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +export interface HdsAppSideNavListTitleSignature { + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +const HdsAppSideNavListTitleComponent = + TemplateOnlyComponent(); + +export default HdsAppSideNavListTitleComponent; diff --git a/packages/components/src/components/hds/app-side-nav/portal/index.hbs b/packages/components/src/components/hds/app-side-nav/portal/index.hbs new file mode 100644 index 0000000000..1b560ca4ec --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/portal/index.hbs @@ -0,0 +1,12 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + +
    + + {{yield ListElements}} + +
    +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/portal/index.ts b/packages/components/src/components/hds/app-side-nav/portal/index.ts new file mode 100644 index 0000000000..f736e0c93d --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/portal/index.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; + +import type { HdsAppSideNavListSignature } from '../list/index'; + +// TODO! understand how this should be done "correctly" +// import type { PortalSignature } from 'ember-stargate/components/portal'; +interface PortalSignature { + Args: { + target: string; + renderInPlace?: boolean; + fallback?: 'inplace'; + }; + Blocks: { + default: []; + }; +} + +export interface HdsAppSideNavPortalSignature { + Args: PortalSignature['Args'] & { + ariaLabel?: string; + targetName?: string; + }; + Blocks: HdsAppSideNavListSignature['Blocks']; + Element: HTMLDivElement; +} + +export default class HdsAppSideNavPortalComponent extends Component {} diff --git a/packages/components/src/components/hds/app-side-nav/portal/target.hbs b/packages/components/src/components/hds/app-side-nav/portal/target.hbs new file mode 100644 index 0000000000..33384679e6 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/portal/target.hbs @@ -0,0 +1,14 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +
    + +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/portal/target.ts b/packages/components/src/components/hds/app-side-nav/portal/target.ts new file mode 100644 index 0000000000..c8769ffde5 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/portal/target.ts @@ -0,0 +1,193 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +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 type { HdsAppSideNavPortalSignature } from './index'; + +// import { PortalTargetSignature } from 'ember-stargate/components/portal-target'; +interface PortalTargetSignature { + Element: HTMLDivElement; + Args: { + name: string; + multiple?: boolean; + onChange?: (count: number) => void; + }; + Blocks: { + default: [number]; + }; +} + +import type { Registry as Services } from '@ember/service'; + +interface HdsAppSideNavPortalTargetSignature { + Args: PortalTargetSignature['Args'] & { + targetName?: HdsAppSideNavPortalSignature['Args']['targetName']; + }; + Element: HTMLDivElement; +} + +export default class HdsAppSideNavPortalTargetComponent extends Component { + @service router!: Services['router']; + + @tracked numSubnavs = 0; + @tracked lastPanelEl: Element | undefined; + + static get prefersReducedMotionOverride(): boolean { + return Ember.testing; + } + + prefersReducedMotionMQ = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ); + + get prefersReducedMotion(): boolean { + return ( + HdsAppSideNavPortalTargetComponent.prefersReducedMotionOverride || + (this.prefersReducedMotionMQ && this.prefersReducedMotionMQ.matches) + ); + } + + @action + panelsChanged(portalCount: number): void { + this.numSubnavs = portalCount; + } + + @action + didUpdateSubnav(element: HTMLElement, [count]: [number]): void { + this.animateSubnav(element, [count]); + } + + @action + animateSubnav(element: HTMLElement, [count]: [number]): void { + /* + * Here is what the layout looks like for this setup + * + + AppSideNav + +----------------------+ + | +------------------+ | + | | ("header") | | + | +------------------+ | + | | + | +------------------+ | + | | ("body") | | + (PortalTarget) | | | | + +----------------------------------------------+ | | + | +----------+ +----------+ | +----------+ | | | + | | (Portal) | | (Portal) | | (Portal) | | | | + | | | | | | | | | | | + | | hidden | | hidden | | *active* | | | | + | | panel | | panel | | | panel | | | | + | | | | | | | | | | + | | | | | | | | | | | + | | | | | | | | | | + | | | | | | | | | | | + | | | | | | | | | | + | | | | | | | | | | | + | | | | | | | | | | + | +----------+ +----------+ | +----------+ | | | + +----------------------------------------------+ | | + | | | | + | +------------------+ | + | | + | +------------------+ | + | | ("footer") | | + | +------------------+ | + +----------------------+ + + * + * every time `HcAppFrame::AppSideNav::Portal` renders, it contains a portaled "panel" + * that is rendered into the `hds-app-side-nav__content-panels` (inside the PortalTarget). + * + * Rendering or unrendering other `HcAppFrame::AppSideNav::Portal`s triggers the number of + * subnavs to change (via `numSubnavs`), so this function runs and slides + * `hds-app-side-nav__content-panels` left or right using the `element.animate` api. + * + * */ + + const activeIndex = count - 1; + const targetElement = element; + const { prefersReducedMotion } = this; + + const styles = getComputedStyle(targetElement); + const columnWidth = styles.getPropertyValue( + '--hds-app-sidenav-width-expanded' + ); + const slideDuration = prefersReducedMotion ? 0 : 150; + let fadeDuration = prefersReducedMotion ? 0 : 175; + let fadeDelay = prefersReducedMotion ? 0 : 50; + + // slide entire parent panel + const start = styles.transform; + const end = `translateX(-${activeIndex * parseInt(columnWidth, 10)}px)`; + const anim = targetElement.animate( + [{ transform: start }, { transform: end }], + { + duration: slideDuration, + easing: 'cubic-bezier(0.65, 0, 0.35, 1)', + fill: 'forwards', + } + ); + + anim.finished.then((): void => { + // uncomment this if we need/want to scroll the element to the top + // targetElement.scrollIntoView(true); + if (activeIndex > 0) { + const allPrev = Array.from(targetElement.children).slice( + 0, + activeIndex + ) as HTMLElement[]; + for (const 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 + const nextPanelEl = targetElement.children[activeIndex] as HTMLElement; + + // get reference to last child panel + const lastPanelEl = targetElement.children[ + targetElement.children.length - 1 + ] as HTMLElement; + + if (nextPanelEl) { + nextPanelEl.ariaHidden = 'false'; + nextPanelEl.style.setProperty('visibility', 'visible'); + // this eliminates a flicker if there's only one subnav rendering or if we + // already just rendered this panel. + if (this.lastPanelEl) { + if (activeIndex === 0 || nextPanelEl.isSameNode(this.lastPanelEl)) { + fadeDelay = 0; + fadeDuration = 0; + } + } + + // remember the last panel + this.lastPanelEl = lastPanelEl; + + nextPanelEl.animate([{ opacity: '0' }, { opacity: '1' }], { + delay: fadeDelay, + duration: fadeDuration, + fill: 'forwards', + }); + } + } +} diff --git a/packages/components/src/components/hds/app-side-nav/toggle-button.hbs b/packages/components/src/components/hds/app-side-nav/toggle-button.hbs new file mode 100644 index 0000000000..aaf35b42d7 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/toggle-button.hbs @@ -0,0 +1,7 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + \ No newline at end of file diff --git a/packages/components/src/components/hds/app-side-nav/toggle-button.ts b/packages/components/src/components/hds/app-side-nav/toggle-button.ts new file mode 100644 index 0000000000..8552f34818 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/toggle-button.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +import type { HdsIconSignature } from '../icon'; + +interface HdsAppSideNavToggleButtonSignature { + Args: { + icon: HdsIconSignature['Args']['name']; + }; + Element: HTMLButtonElement; +} + +const HdsAppSideNavToggleButtonComponent = + TemplateOnlyComponent(); + +export default HdsAppSideNavToggleButtonComponent; diff --git a/packages/components/src/styles/@hashicorp/design-system-components.scss b/packages/components/src/styles/@hashicorp/design-system-components.scss index 9f67d4d0ec..43bd4558ce 100644 --- a/packages/components/src/styles/@hashicorp/design-system-components.scss +++ b/packages/components/src/styles/@hashicorp/design-system-components.scss @@ -17,6 +17,7 @@ @use "../components/app-footer"; @use "../components/app-frame"; @use "../components/app-header"; +@use "../components/app-side-nav"; @use "../components/application-state"; @use "../components/badge"; @use "../components/badge-count"; diff --git a/packages/components/src/styles/components/app-side-nav/a11y-refocus.scss b/packages/components/src/styles/components/app-side-nav/a11y-refocus.scss new file mode 100644 index 0000000000..1d3285c9f2 --- /dev/null +++ b/packages/components/src/styles/components/app-side-nav/a11y-refocus.scss @@ -0,0 +1,30 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// +// APP-SIDE-NAV > A11Y-REFOCUS "SKIP LINK" +// + +.hds-app-side-nav { // this extra qualifier is needed to increase specificity of the selector, please don't remove it + .ember-a11y-refocus-skip-link { + top: 10px; + left: 10px; + z-index: 20; + width: max-content; + padding: 2px 10px 4px; + color: var(--token-color-foreground-action); + font-size: var(--token-typography-display-200-font-size); + font-family: var(--token-typography-display-200-font-family); + line-height: var(--token-typography-display-200-line-height); + background-color: var(--token-color-surface-faint); + border-radius: 3px; + transform: translateY(-200%); + transition: 0.6s ease-in-out; + + &:focus { + transform: translateY(0); + } + } +} diff --git a/packages/components/src/styles/components/app-side-nav/content.scss b/packages/components/src/styles/components/app-side-nav/content.scss new file mode 100644 index 0000000000..ac061d0a87 --- /dev/null +++ b/packages/components/src/styles/components/app-side-nav/content.scss @@ -0,0 +1,162 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// +// SIDE-NAV > CONTENT (PORTALS + LISTS OF ITEMS/LINKS) +// + +@use "../../mixins/focus-ring" as *; + + +// PANELS (wrappers used in conjunction with the portal elements) + +.hds-app-side-nav__content { + // we use this trick (increasing the container size here, and reducing it at single panel level) + // to have the panels width match the sidebar extended width (it's used in the animated sliding of the panels) + margin: 0 calc(var(--token-app-side-nav-wrapper-padding-horizontal) * -1); + + // we hide the content when the SideNav is collapsed to prevent the vertical scrollbar from being visible + // when the scrollbar is set to be always visible or a mouse or trackpad force it to be always visible. + // ideally we would use `display: none` but doing so would disable the fade-in transition when expanding + .hds-app-side-nav--is-minimized & { + height: 0; + overflow: hidden; + } +} + +.hds-app-side-nav__content-panels { + // see https://codepen.io/didoo/pen/YzOeRPr + display: grid; + grid-template-columns: repeat(5, var(--hds-app-sidenav-width-expanded)); + width: 100%; +} + +.hds-app-side-nav__content-panel { + padding: 0 var(--token-app-side-nav-wrapper-padding-horizontal); +} + +// (LIST) TITLE + +.hds-app-side-nav__list-title { + min-height: var(--token-app-side-nav-body-list-item-height); + margin-top: var(--token-app-side-nav-body-list-margin-vertical); + padding: 9px var(--token-app-side-nav-body-list-item-padding-horizontal); // 8px = (min-height - body-100-line-height) / 2 + color: var(--token-app-side-nav-color-foreground-faint); + overflow-wrap: break-word; + + // Remove margin from title at top of all list-items & lists + .hds-app-side-nav__list-wrapper:first-child .hds-app-side-nav__list-item:first-child > & { + margin-top: 0; + } +} + +// LIST (root elements) + +.hds-app-side-nav__list-wrapper, //