diff --git a/.changeset/seven-mugs-think.md b/.changeset/seven-mugs-think.md new file mode 100644 index 0000000000..308d1744b9 --- /dev/null +++ b/.changeset/seven-mugs-think.md @@ -0,0 +1,5 @@ +--- +"@hashicorp/design-system-components": minor +--- + +`SideNav` - Deprecated the `SideNav` component. Use the [`AppSideNav` component](/components/app-side-nav) as a replacement. diff --git a/.changeset/silver-planes-occur.md b/.changeset/silver-planes-occur.md new file mode 100644 index 0000000000..a9bfbfa794 --- /dev/null +++ b/.changeset/silver-planes-occur.md @@ -0,0 +1,5 @@ +--- +"@hashicorp/design-system-components": minor +--- + +`Hds::AppSideNav` - Added the component. It is meant to replace the standalone `SideNav` component and be paired together with the `AppHeader` which should both be contained within the `AppFrame` layout component. diff --git a/packages/components/package.json b/packages/components/package.json index 3009b942fd..1414dcfcaa 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -137,6 +137,15 @@ "./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/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/index.hbs b/packages/components/src/components/hds/app-side-nav/index.hbs new file mode 100644 index 0000000000..6e3bec268a --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/index.hbs @@ -0,0 +1,34 @@ +{{! + 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 }} +
+

Application local navigation

+ +
+ {{#if this.showToggleButton}} + {{! template-lint-disable no-invalid-interactive}} +
+ {{! template-lint-enable no-invalid-interactive}} + + {{/if}} + +
+ {{~yield~}} +
+
+
\ 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..ee0038d41d --- /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 { registerDestructor } from '@ember/destroyable'; + +export interface HdsAppSideNavSignature { + Args: { + isResponsive?: boolean; + isCollapsible?: boolean; + isMinimized?: boolean; + onToggleMinimizedStatus?: (arg: boolean) => void; + onDesktopViewportChange?: (arg: boolean) => void; + }; + Blocks: { + default?: []; + }; + Element: HTMLDivElement; +} + +export default class HdsAppSideNav extends Component { + @tracked isMinimized; + @tracked isAnimating = false; + @tracked isDesktop = true; + + body!: HTMLElement; + bodyInitialOverflowValue = ''; + desktopMQ: MediaQueryList; + containersToHide!: NodeListOf; + + desktopMQVal = getComputedStyle(document.documentElement).getPropertyValue( + '--hds-app-desktop-breakpoint' + ); + + constructor(owner: unknown, args: HdsAppSideNavSignature['Args']) { + super(owner, args); + this.isMinimized = this.args.isMinimized ?? false; // sets the default state on 'desktop' viewports + this.desktopMQ = window.matchMedia(`(min-width:${this.desktopMQVal})`); + this.addEventListeners(); + registerDestructor(this, (): void => { + this.removeEventListeners(); + }); + } + + 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 + ); + } + + // controls if the component reacts to viewport changes + get isResponsive(): boolean { + return this.args.isResponsive ?? true; + } + + // controls if users can collapse the appsidenav on 'desktop' viewports + get isCollapsible(): boolean { + return this.args.isCollapsible ?? false; + } + + get shouldTrapFocus(): boolean { + return this.isResponsive && !this.isDesktop && !this.isMinimized; + } + + get showToggleButton(): boolean { + return (this.isResponsive && !this.isDesktop) || this.isCollapsible; + } + + get classNames(): string { + const classes = [`hds-app-side-nav`]; + + // 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(' '); + } + + synchronizeInert(): void { + this.containersToHide?.forEach((element): void => { + if (this.isMinimized) { + element.setAttribute('inert', ''); + } else { + element.removeAttribute('inert'); + } + }); + } + + lockBodyScroll(): void { + if (this.body) { + // Prevent page from scrolling when the dialog is open + this.body.style.setProperty('overflow', 'hidden'); + } + } + + unlockBodyScroll(): void { + // Reset page `overflow` property + if (this.body) { + this.body.style.removeProperty('overflow'); + if (this.bodyInitialOverflowValue === '') { + if (this.body.style.length === 0) { + this.body.removeAttribute('style'); + } + } else { + this.body.style.setProperty('overflow', this.bodyInitialOverflowValue); + } + } + } + + @action + escapePress(event: KeyboardEvent): void { + if (event.key === 'Escape' && !this.isMinimized && !this.isDesktop) { + this.isMinimized = true; + this.synchronizeInert(); + } + } + + @action + toggleMinimizedStatus(): void { + this.isMinimized = !this.isMinimized; + this.synchronizeInert(); + + const { onToggleMinimizedStatus } = this.args; + + if (typeof onToggleMinimizedStatus === 'function') { + onToggleMinimizedStatus(this.isMinimized); + } + + if (this.isMinimized) { + this.unlockBodyScroll(); + } else { + this.lockBodyScroll(); + } + } + + @action + didInsert(element: HTMLElement): void { + this.containersToHide = element.querySelectorAll( + '.hds-app-side-nav-hide-when-minimized' + ); + this.body = document.body; + // Store the initial `overflow` value of `` so we can reset to it + this.bodyInitialOverflowValue = + this.body.style.getPropertyValue('overflow'); + } + + @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; + + this.synchronizeInert(); + + 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..4cd0e21d8e --- /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 HdsAppSideNavListBackLink = + TemplateOnlyComponent(); + +export default HdsAppSideNavListBackLink; 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..c82c85d635 --- /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..7d9f3a5c80 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/index.ts @@ -0,0 +1,43 @@ +/** + * 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 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; +} + +export default class HdsAppSideNavList extends Component { + @tracked _titleIds: string[] = []; + + get titleIds(): string { + return this._titleIds.join(' '); + } + + @action + didInsertTitle(titleId: string): void { + this._titleIds = [...this._titleIds, titleId]; + } +} 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..4dcd422618 --- /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 HdsAppSideNavListItem = + TemplateOnlyComponent(); + +export default HdsAppSideNavListItem; 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..1c959972de --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/link.hbs @@ -0,0 +1,51 @@ +{{! + 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..6d22928fe1 --- /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 HdsAppSideNavListLink = + TemplateOnlyComponent(); + +export default HdsAppSideNavListLink; 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..91530a0d5d --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/title.hbs @@ -0,0 +1,13 @@ +{{! + 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..31bc0232dd --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/list/title.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { guidFor } from '@ember/object/internals'; +import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +export interface HdsAppSideNavListTitleSignature { + Args: { + didInsertTitle?: (titleId: string) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsAppSideNavListTitle extends Component { + /* Generate a unique ID for each Title */ + titleId = 'title-' + guidFor(this); + + @action + didInsertTitle(element: HTMLElement): void { + const { didInsertTitle } = this.args; + + if (typeof didInsertTitle === 'function') { + didInsertTitle(element.id); + } + } +} 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..10367dc5d9 --- /dev/null +++ b/packages/components/src/components/hds/app-side-nav/portal/index.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import TemplateOnlyComponent from '@ember/component/template-only'; + +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; +} + +const HdsAppSideNavPortal = + TemplateOnlyComponent(); + +export default HdsAppSideNavPortal; 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..c2b15882b2 --- /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 { macroCondition, isTesting } from '@embroider/macros'; + +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 HdsAppSideNavPortalTarget extends Component { + @service router!: Services['router']; + + @tracked numSubnavs = 0; + @tracked lastPanelEl: Element | undefined; + + static get prefersReducedMotionOverride(): boolean { + return macroCondition(isTesting()) ? true : false; + } + + prefersReducedMotionMQ = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ); + + get prefersReducedMotion(): boolean { + return ( + HdsAppSideNavPortalTarget.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 ascii art of 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::SideNav::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::SideNav::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..11dbe1f0f0 --- /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 HdsAppSideNavToggleButton = + TemplateOnlyComponent(); + +export default HdsAppSideNavToggleButton; diff --git a/packages/components/src/components/hds/side-nav/index.hbs b/packages/components/src/components/hds/side-nav/index.hbs index 8581d193a4..eb9dd44987 100644 --- a/packages/components/src/components/hds/side-nav/index.hbs +++ b/packages/components/src/components/hds/side-nav/index.hbs @@ -2,6 +2,10 @@ Copyright (c) HashiCorp, Inc. SPDX-License-Identifier: MPL-2.0 }} +{{! + THIS COMPONENT IS NOW DEPRECATED +}} + {{! 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 }} { constructor(owner: unknown, args: HdsSideNavSignature['Args']) { super(owner, args); + this.desktopMQ = window.matchMedia(`(min-width:${this.desktopMQVal})`); this.addEventListeners(); registerDestructor(this, (): void => { this.removeEventListeners(); }); + + deprecate( + 'The `Hds::SideNav` component is now deprecated and will be removed in the next major version of `@hashicorp/design-system-components`. Use `Hds::AppSideNav` instead.', + false, + { + id: 'hds.components.sidenav', + until: '5.0.0', + url: 'https://helios.hashicorp.design/components/side-nav?tab=version%20history#4140', + for: '@hashicorp/design-system-components', + since: { + enabled: '4.14.0', + }, + } + ); } addEventListeners(): void { 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/content.scss b/packages/components/src/styles/components/app-side-nav/content.scss new file mode 100644 index 0000000000..ac86e2c92e --- /dev/null +++ b/packages/components/src/styles/components/app-side-nav/content.scss @@ -0,0 +1,182 @@ +/** + * 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-side-nav-width-expanded)); + width: 100%; +} + +.hds-app-side-nav__content-panel { + padding: 0 var(--token-app-side-nav-wrapper-padding-horizontal); + overflow: hidden; // the panel itself does not need to be scrollable + + &[aria-hidden="true"] { + max-height: 0; // prevents hidden panels from causing scrolling + } +} + +// (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, //