From a80cfc54f41b4e104d13baf9ee7a2d8c10e95820 Mon Sep 17 00:00:00 2001 From: Matt Hippely Date: Thu, 11 Feb 2021 07:58:58 -0800 Subject: [PATCH] feat(core:navigation): mvp cds-navigation - this is vertical axis only - custom expand/collapse icons for root navigation and navigation groups - navigation groups - arrow and home and end key navigation - adds mdx and demos to core storybook Signed-off-by: Matt Hippely --- packages/core/rollup.config.js | 1 + packages/core/src/internal/index.ts | 5 + .../animations/cds-navigation-group-open.ts | 23 + .../motion/animations/cds-navigation-open.ts | 20 + .../src/internal/services/i18n.service.ts | 6 + packages/core/src/internal/utils/array.ts | 2 +- packages/core/src/internal/utils/lit.ts | 8 + .../src/navigation/entrypoint.tsconfig.json | 12 + packages/core/src/navigation/index.ts | 10 + .../interfaces/navigation.interfaces.ts | 13 + .../navigation/navigation-group.element.scss | 48 + .../navigation-group.element.spec.ts | 84 + .../navigation/navigation-group.element.ts | 191 +++ .../navigation/navigation-item.element.scss | 67 + .../navigation-item.element.spec.ts | 104 ++ .../src/navigation/navigation-item.element.ts | 115 ++ .../navigation/navigation-start.element.scss | 48 + .../navigation-start.element.spec.ts | 192 +++ .../navigation/navigation-start.element.ts | 169 ++ .../src/navigation/navigation.element.scss | 67 + .../src/navigation/navigation.element.spec.ts | 385 +++++ .../core/src/navigation/navigation.element.ts | 441 +++++ .../src/navigation/navigation.stories.mdx | 117 ++ .../core/src/navigation/navigation.stories.ts | 585 +++++++ packages/core/src/navigation/register.ts | 44 + .../core/src/navigation/utils/index.spec.ts | 163 ++ packages/core/src/navigation/utils/index.ts | 63 + packages/core/web-dev-server.config.mjs | 2 + packages/core/web-test-runner.config.mjs | 17 +- packages/react/App.tsx | 1424 +++++++++-------- .../__snapshots__/index.test.tsx.snap | 84 + .../src/navigation/entrypoint.tsconfig.json | 7 + packages/react/src/navigation/index.test.tsx | 57 + packages/react/src/navigation/index.tsx | 25 + packages/react/src/navigation/package.json | 4 + packages/react/tsconfig.project.json | 1 + 36 files changed, 3901 insertions(+), 703 deletions(-) create mode 100644 packages/core/src/internal/motion/animations/cds-navigation-group-open.ts create mode 100644 packages/core/src/internal/motion/animations/cds-navigation-open.ts create mode 100644 packages/core/src/navigation/entrypoint.tsconfig.json create mode 100644 packages/core/src/navigation/index.ts create mode 100644 packages/core/src/navigation/interfaces/navigation.interfaces.ts create mode 100644 packages/core/src/navigation/navigation-group.element.scss create mode 100644 packages/core/src/navigation/navigation-group.element.spec.ts create mode 100644 packages/core/src/navigation/navigation-group.element.ts create mode 100644 packages/core/src/navigation/navigation-item.element.scss create mode 100644 packages/core/src/navigation/navigation-item.element.spec.ts create mode 100644 packages/core/src/navigation/navigation-item.element.ts create mode 100644 packages/core/src/navigation/navigation-start.element.scss create mode 100644 packages/core/src/navigation/navigation-start.element.spec.ts create mode 100644 packages/core/src/navigation/navigation-start.element.ts create mode 100644 packages/core/src/navigation/navigation.element.scss create mode 100644 packages/core/src/navigation/navigation.element.spec.ts create mode 100644 packages/core/src/navigation/navigation.element.ts create mode 100644 packages/core/src/navigation/navigation.stories.mdx create mode 100644 packages/core/src/navigation/navigation.stories.ts create mode 100644 packages/core/src/navigation/register.ts create mode 100644 packages/core/src/navigation/utils/index.spec.ts create mode 100644 packages/core/src/navigation/utils/index.ts create mode 100644 packages/react/src/navigation/__snapshots__/index.test.tsx.snap create mode 100644 packages/react/src/navigation/entrypoint.tsconfig.json create mode 100644 packages/react/src/navigation/index.test.tsx create mode 100644 packages/react/src/navigation/index.tsx create mode 100644 packages/react/src/navigation/package.json diff --git a/packages/core/rollup.config.js b/packages/core/rollup.config.js index 1f94d9ce01..32375cfea4 100644 --- a/packages/core/rollup.config.js +++ b/packages/core/rollup.config.js @@ -49,6 +49,7 @@ const config = { './src/internal-components/overlay', './src/internal-components/panel', './src/modal', + './src/navigation', './src/pagination', './src/password', './src/progress-circle', diff --git a/packages/core/src/internal/index.ts b/packages/core/src/internal/index.ts index c6184aa594..4573fc7d49 100644 --- a/packages/core/src/internal/index.ts +++ b/packages/core/src/internal/index.ts @@ -56,3 +56,8 @@ export { } from './motion/animations/cds-accordion-panel-open.js'; export { AnimationHingeConfig, AnimationHingeName } from './motion/animations/cds-overlay-hinge-example.js'; export { AnimationShakeConfig, AnimationShakeName } from './motion/animations/cds-component-shake.js'; +export { + AnimationNavigationGroupOpenConfig, + AnimationNavigationGroupOpenName, +} from './motion/animations/cds-navigation-group-open.js'; +export { AnimationNavigationOpenConfig, AnimationNavigationOpenName } from './motion/animations/cds-navigation-open.js'; diff --git a/packages/core/src/internal/motion/animations/cds-navigation-group-open.ts b/packages/core/src/internal/motion/animations/cds-navigation-group-open.ts new file mode 100644 index 0000000000..27606929de --- /dev/null +++ b/packages/core/src/internal/motion/animations/cds-navigation-group-open.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { TargetedAnimation } from '../interfaces.js'; + +export const AnimationNavigationGroupOpenName = 'cds-navigation-group-open'; +export const AnimationNavigationGroupOpenConfig: TargetedAnimation[] = [ + { + target: '.navigation-group-items', + animation: [ + { opacity: 0, height: '0' }, + { opacity: 1, height: 'from:cds-navigation-group' }, + ], + options: { + duration: '--animation-duration', + easing: '--animation-easing', + fill: 'forwards', + }, + }, +]; diff --git a/packages/core/src/internal/motion/animations/cds-navigation-open.ts b/packages/core/src/internal/motion/animations/cds-navigation-open.ts new file mode 100644 index 0000000000..0ddc879825 --- /dev/null +++ b/packages/core/src/internal/motion/animations/cds-navigation-open.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { TargetedAnimation } from '../interfaces.js'; + +export const AnimationNavigationOpenName = 'cds-navigation-open'; +export const AnimationNavigationOpenConfig: TargetedAnimation[] = [ + { + target: 'cds-navigation', + animation: [{ width: 'var(--collapsed-width)' }, { width: 'var(--expanded-width)' }], + options: { + duration: '--animation-duration', + easing: '--animation-easing', + fill: 'forwards', + }, + }, +]; diff --git a/packages/core/src/internal/services/i18n.service.ts b/packages/core/src/internal/services/i18n.service.ts index 598cee816b..4575e7a635 100644 --- a/packages/core/src/internal/services/i18n.service.ts +++ b/packages/core/src/internal/services/i18n.service.ts @@ -58,6 +58,12 @@ export const componentStringsDefault = { contentBox: 'Scrollable Modal Body', contentEnd: 'End of Modal Content', }, + navigation: { + navigationElement: 'navigation', + navigationLabel: 'navigation menu', + navigationAbridgedText: 'View abridged menu', + navigationUnabridgedText: 'View unabridged menu', + }, password: { showButtonAriaLabel: 'Show password', hideButtonAriaLabel: 'Hide password', diff --git a/packages/core/src/internal/utils/array.ts b/packages/core/src/internal/utils/array.ts index ba6d8b8d3b..c322079ed8 100644 --- a/packages/core/src/internal/utils/array.ts +++ b/packages/core/src/internal/utils/array.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. * This software is released under MIT license. * The full license information can be found in LICENSE in the root directory of this project. */ diff --git a/packages/core/src/internal/utils/lit.ts b/packages/core/src/internal/utils/lit.ts index 550bcea92b..9ac7b9aaae 100644 --- a/packages/core/src/internal/utils/lit.ts +++ b/packages/core/src/internal/utils/lit.ts @@ -41,3 +41,11 @@ export function syncProps( .filter(c => conditions[c]) .forEach(c => (target[c] = source[c])); } + +export function syncPropsForAllItems( + targets: { [prop: string]: any }[], + source: { [prop: string]: any }, + conditions: { [prop: string]: boolean } +) { + targets.forEach(target => syncProps(target, source, conditions)); +} diff --git a/packages/core/src/navigation/entrypoint.tsconfig.json b/packages/core/src/navigation/entrypoint.tsconfig.json new file mode 100644 index 0000000000..6b86f138ef --- /dev/null +++ b/packages/core/src/navigation/entrypoint.tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { "path": "../button/entrypoint.tsconfig.json" }, + { "path": "../divider/entrypoint.tsconfig.json" }, + { "path": "../internal/entrypoint.tsconfig.json" }, + { "path": "../icon/entrypoint.tsconfig.json" } + ] +} diff --git a/packages/core/src/navigation/index.ts b/packages/core/src/navigation/index.ts new file mode 100644 index 0000000000..357eb6da03 --- /dev/null +++ b/packages/core/src/navigation/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +export * from './navigation.element.js'; +export * from './navigation-group.element.js'; +export * from './navigation-start.element.js'; +export * from './navigation-item.element.js'; diff --git a/packages/core/src/navigation/interfaces/navigation.interfaces.ts b/packages/core/src/navigation/interfaces/navigation.interfaces.ts new file mode 100644 index 0000000000..049febe04e --- /dev/null +++ b/packages/core/src/navigation/interfaces/navigation.interfaces.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +export interface FocusableItem { + id: string; + hasFocus: boolean; + focusElement: HTMLElement; +} + +export type NavigationFocusState = true | false; diff --git a/packages/core/src/navigation/navigation-group.element.scss b/packages/core/src/navigation/navigation-group.element.scss new file mode 100644 index 0000000000..b3116a3f4a --- /dev/null +++ b/packages/core/src/navigation/navigation-group.element.scss @@ -0,0 +1,48 @@ +/*! + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +@import './../styles/tokens/generated/index'; + +:host { + --animation-duration: #{$cds-global-animation-duration-secondary}; + --animation-easing: #{$cds-global-animation-easing-primary}; + --background: inherit; +} + +.navigation-group-items { + height: 0; + overflow-y: hidden; +} + +:host([cds-motion='off']) { + .navigation-group-items { + height: 0; + } +} + +:host([cds-motion='off'][expanded]) { + .navigation-group-items { + height: auto; + } +} + +:host([cds-motion][_cds-animation-status='ready']:not([cds-motion='off'])) { + .navigation-group-items { + height: 0; + } +} + +:host([cds-motion][expanded][_cds-animation-status='ready']:not([cds-motion='off'])) { + .navigation-group-items { + height: auto !important; + transform: none; + } +} + +.group-items-container, +.group-items-wrapper { + width: 100%; +} diff --git a/packages/core/src/navigation/navigation-group.element.spec.ts b/packages/core/src/navigation/navigation-group.element.spec.ts new file mode 100644 index 0000000000..ffe19fdd75 --- /dev/null +++ b/packages/core/src/navigation/navigation-group.element.spec.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html } from 'lit'; +import { createTestElement, componentIsStable, removeTestElement } from '@cds/core/test'; +import { CdsNavigationGroup, CdsNavigationStart } from '@cds/core/navigation'; +import '@cds/core/navigation/register.js'; +import Spy = jasmine.Spy; + +describe('cds-navigation-group', () => { + let component: CdsNavigationGroup; + let element: HTMLElement; + let click: MouseEvent; + + beforeEach(async () => { + element = await createTestElement(html` + + Group nav start + group nav item + + `); + component = element.querySelector('cds-navigation-group'); + click = new MouseEvent('click'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('should create a navigation group component', async () => { + await componentIsStable(component); + expect(component).toBeTruthy(); + }); + + it('expands and collapses', async () => { + await componentIsStable(component); + const items = component.shadowRoot.querySelector('.navigation-group-items'); + expect(items.getAttribute('aria-expanded')).toBe('false'); + component.expanded = true; + await componentIsStable(component); + expect(items.getAttribute('aria-expanded')).toBe('true'); + }); + + it('emits the expandedChange event', async () => { + await componentIsStable(component); + let count = 0; + const expandedSpy: Spy = jasmine.createSpy('expandedChange').and.callFake(() => { + count++; + }); + component.addEventListener('expandedChange', expandedSpy); + const startEle = component.querySelector('cds-navigation-start'); + startEle.dispatchEvent(click); + await componentIsStable(component); + expect(count).toBe(1); + }); + + it('has accessible navigationGroupId', async () => { + await componentIsStable(component); + expect(component.navigationGroupId).toBeTruthy(); + const itemWrapper = component.shadowRoot.querySelector('.group-items-wrapper'); + expect(itemWrapper.getAttribute('aria-labelledby')).toBe(component.navigationGroupId); + }); + + it('can be active', async () => { + await componentIsStable(component); + component.setAttribute('active', ''); + await componentIsStable(component); + expect(component.active).toBe(true); + }); + + it('syncs expandedState down to children items', async () => { + await componentIsStable(component); + const startEle = component.querySelector('cds-navigation-start'); + expect(startEle.navigationGroupId).toBe(component.navigationGroupId); + expect(startEle.isGroupStart).toBe(true); + expect(startEle.expanded).toBe(component.expanded); + component.setAttribute('expanded', ''); + await componentIsStable(component); + expect(startEle.expanded).toBe(component.expanded); + }); +}); diff --git a/packages/core/src/navigation/navigation-group.element.ts b/packages/core/src/navigation/navigation-group.element.ts new file mode 100644 index 0000000000..5eb6b70454 --- /dev/null +++ b/packages/core/src/navigation/navigation-group.element.ts @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html, LitElement, PropertyValues } from 'lit'; + +import { + Animatable, + animate, + AnimationNavigationGroupOpenName, + baseStyles, + event, + EventEmitter, + i18n, + I18nService, + id, + property, + querySlot, + querySlotAll, + reverseAnimation, + state, + syncProps, + syncPropsForAllItems, +} from '@cds/core/internal'; +import styles from './navigation-group.element.scss'; +import { CdsNavigationItem } from './navigation-item.element'; +import { CdsNavigationStart } from './navigation-start.element'; + +export const CdsNavigationGroupTagName = 'cds-navigation-group'; + +/** + * + * ```typescript + * import '@cds/core/navigation/register.js'; + * ``` + * + * ```html + * + * + * Home + * Account + * + * ``` + * + * @beta + * @element cds-navigation-group + * @event expandedChange - notify when the user has clicked the navigation expand/collapse button + * @cssprop --animation-duration + * @cssprop --animation-easing + * @cssprop --background + * @slot + */ +@animate({ + expanded: { + true: AnimationNavigationGroupOpenName, + false: reverseAnimation(AnimationNavigationGroupOpenName), + }, +}) +export class CdsNavigationGroup extends LitElement implements Animatable { + @property({ type: String }) + cdsMotion = 'on'; + + // Keep this from polluting the wca output and causing @cds/angular issues when the directives are generated. + /** @private **/ + @event() + expandedChange: EventEmitter; + + @event() + cdsMotionChange: EventEmitter; + + /** + * @desc + * Associate the (projected) cds-navigation-button with group-items-wrapper (aria-labelledby) + * + * @private + */ + @id() navigationGroupId: string; + + @i18n() i18n = I18nService.keys.navigation; + + /** + * @description + * Getter method for a reference to the selector cds-navigation-group > cds-navigation-start + * This lets each group flag its cds-navigation-start element and sync that info down. This is + * needed because cds-navigation-start elements can be used at the root level and inside + * cds-navigation-group elements. + * + * @private + */ + @state() + private get isGroupStart(): boolean { + return !!this.groupStart; + } + + @property({ type: Boolean, reflect: true }) + expanded = false; + + @property({ type: Boolean }) + active: boolean; + + /** + * @desc + * The value of this property is passed down to start and item children. It is used to query for visible items when + * managing focus with key events in the root cds-navigation element. + * + * Note: eslint-disable @typescript-eslint/no-unused-vars isn't ignoring the line + // eslint error happens because the value is set but never read. + + * @private + */ + + @state() + expandedGroup = false; + + /** + * @desc + * + * Used to coordinate css things and the keyboard navigation focus changes. + */ + hasFocus = false; + + @querySlot(':scope > cds-navigation-start', { assign: 'group-start' }) + protected groupStart: CdsNavigationStart; + + @querySlotAll(':scope > cds-navigation-item') + protected groupItems: NodeListOf; + + @querySlotAll(':scope > cds-navigation-group') + protected nestedGroups: NodeListOf; + + render() { + return html` +
+ +
+
+ +
+
+
+ `; + } + + static get styles() { + return [baseStyles, styles]; + } + + private toggle() { + this.expandedChange.emit(!this.expanded); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.groupStart.removeEventListener('click', this.toggle.bind(this)); + } + + protected firstUpdated(props: PropertyValues) { + super.firstUpdated(props); + if (this.groupStart) { + this.groupStart.addEventListener('click', this.toggle.bind(this)); + } + } + + updated(props: PropertyValues) { + super.updated(props); + if (props.has('expanded')) { + this.expandedGroup = this.expanded; + } + + if (this.groupStart) { + syncProps(this.groupStart, this, { + active: true, + expanded: true, + isGroupStart: this.isGroupStart, + navigationGroupId: true, + }); + } + + syncPropsForAllItems(Array.from(this.groupItems), this, { + expandedGroup: true, + }); + } +} diff --git a/packages/core/src/navigation/navigation-item.element.scss b/packages/core/src/navigation/navigation-item.element.scss new file mode 100644 index 0000000000..cc712dda1d --- /dev/null +++ b/packages/core/src/navigation/navigation-item.element.scss @@ -0,0 +1,67 @@ +/*! + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +@import './../styles/tokens/generated/index'; + +:host { + --color: inherit; + --line-height: inherit; + --font-size: inherit; + --font-weight: inherit; + --letter-spacing: inherit; + --padding: inherit; +} + +:host(:hover:not([disabled])), +:host([_has-focus]) { + background: #{$cds-alias-object-interaction-background-hover}; +} + +:host(:active), +:host([active]) { + background: #{$cds-alias-object-interaction-background-active}; +} + +:host([selected]) { + background: #{$cds-alias-object-interaction-background-selected}; +} + +:host([disabled]) { + --color: #{$cds-alias-object-interaction-color-disabled}; + + // disabled items should not respond to hover + &:hover { + --background: inherit; + } + + ::slotted(a) { + cursor: not-allowed; + } +} + +:host([_group-item]) { + padding: var(--padding); +} + +::slotted(cds-icon) { + color: inherit; +} + +// These are not our elements and the consumer can easily override us here. +::slotted(a) { + color: var(--color) !important; + padding: var(--padding) !important; + text-decoration: none !important; + outline: none !important; +} + +.private-host { + font-size: var(--font-size); + font-weight: var(--font-weight); + letter-spacing: var(--letter-spacing); + line-height: var(--line-height); + width: 100%; +} diff --git a/packages/core/src/navigation/navigation-item.element.spec.ts b/packages/core/src/navigation/navigation-item.element.spec.ts new file mode 100644 index 0000000000..cede58b122 --- /dev/null +++ b/packages/core/src/navigation/navigation-item.element.spec.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html } from 'lit'; +import { createTestElement, removeTestElement, componentIsStable } from '@cds/core/test'; +import { CdsNavigationGroup, CdsNavigationItem } from '@cds/core/navigation'; +import '@cds/core/navigation/register.js'; + +describe('cds-navigation-item', () => { + let component: CdsNavigationItem; + let element: HTMLElement; + + beforeEach(async () => { + element = await createTestElement( + html`
Navigation item` + ); + component = element.querySelector('cds-navigation-item'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('should create a navigation item component', async () => { + await componentIsStable(component); + expect(component).toBeTruthy(); + }); + + it('wraps text inside a span element', async () => { + await componentIsStable(component); + const wrappedText = component.querySelector('[cds-navigation-sr-text]'); + + expect(wrappedText.textContent.trim()).toEqual('Navigation item'); + }); + + it('manages screen reader text', async () => { + await componentIsStable(component); + const wrappedSpan = component.querySelector('span[cds-layout]'); + expect(wrappedSpan.getAttribute('cds-layout')).toBe('display:screen-reader-only'); + component.expanded = true; + await componentIsStable(component); + expect(wrappedSpan.getAttribute('cds-layout')).toBeFalsy(); + }); + + it('has a id attribute', async () => { + await componentIsStable(component); + expect(component.getAttribute('id')).toBeTruthy(); + }); + + it('sets the correct focusElement', async () => { + const anchor: HTMLAnchorElement = component.querySelector('#anchor'); + expect(component.focusElement).toBe(anchor); + }); + + it('can be focused', async () => { + await componentIsStable(component); + expect(component.getAttribute('_has-focus')).toBeFalsy(); + component.hasFocus = true; + await componentIsStable(component); + expect(component.getAttribute('_has-focus')).toBe(''); + }); + + it('cannot be focused when disabled', async () => { + await componentIsStable(component); + expect(component.getAttribute('disabled')).toBeNull(); + expect(component.getAttribute('tabindex')).toBeNull(); + component.setAttribute('disabled', ''); + await componentIsStable(component); + expect(component.getAttribute('disabled')).toBe(''); + expect(component.getAttribute('tabindex')).toBe('-1'); + }); + + describe('when inside a cds-navigation-group', () => { + let group: CdsNavigationGroup; + let item: CdsNavigationItem; + let element: HTMLElement; + + beforeEach(async () => { + element = await createTestElement(html` + + + Navigation item + + `); + group = element.querySelector('cds-navigation-group'); + item = element.querySelector('cds-navigation-item'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('reflects the expandedGroup property', async () => { + await componentIsStable(group); + expect(item.getAttribute('_expanded-group')).toBeNull(); + group.setAttribute('expanded', ''); + await componentIsStable(group); + expect(item.getAttribute('_expanded-group')).toBe(''); + }); + }); +}); diff --git a/packages/core/src/navigation/navigation-item.element.ts b/packages/core/src/navigation/navigation-item.element.ts new file mode 100644 index 0000000000..5930f53b85 --- /dev/null +++ b/packages/core/src/navigation/navigation-item.element.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html, LitElement, PropertyValues } from 'lit'; +import { + baseStyles, + createId, + i18n, + I18nService, + state, + property, + querySlot, + querySlotAll, + spanWrapper, +} from '@cds/core/internal'; +import styles from './navigation-item.element.scss'; +import { manageScreenReaderElements, NAVIGATION_TEXT_WRAPPER } from './utils/index.js'; +import { CdsIcon } from '@cds/core/icon/icon.element.js'; +import { FocusableItem, NavigationFocusState } from './interfaces/navigation.interfaces.js'; + +export const CdsNavigationItemTagName = 'cds-navigation-item'; + +/** + * ```typescript + * import '@cds/core/navigation/register.js'; + * ``` + * + * ```html + * Home + * ``` + * + * @beta + * @element cds-navigation-item + * @cssprop --color + * @cssprop --font-size + * @cssprop --font-weight + * @cssprop --letter-spacing + * @cssprop --padding + * @slot + */ +export class CdsNavigationItem extends LitElement implements FocusableItem { + @i18n() i18n = I18nService.keys.navigation; + + @property({ type: Boolean, reflect: true }) + active = false; + + @property({ type: Boolean, reflect: true }) + disabled = false; + + @state({ type: Boolean }) + expanded = false; + + @state({ type: Boolean, reflect: true }) + protected expandedGroup = true; + + @state({ type: Boolean, reflect: true }) + groupItem: boolean; + + @state({ type: Boolean, reflect: true }) + hasFocus: NavigationFocusState = false; + + @querySlot('a') + focusElement: HTMLElement; + + @querySlot('cds-icon', { assign: 'cds-icon-slot' }) + protected itemIcon: CdsIcon; + + @querySlotAll('[cds-navigation-sr-text]') + itemText: NodeListOf; + + connectedCallback() { + super.connectedCallback(); + if (!this.id) { + this.id = createId(); + } + } + + firstUpdated(props: PropertyValues) { + super.firstUpdated(props); + this.handleItemAnchorText(); + } + + private handleItemAnchorText() { + const anchorElement = this.querySelector('a'); + if (anchorElement) { + spanWrapper(anchorElement.childNodes); + anchorElement?.querySelector('span')?.setAttribute(NAVIGATION_TEXT_WRAPPER, ''); + } + } + + render() { + return html` +
+ +
+ `; + } + + static get styles() { + return [baseStyles, styles]; + } + + protected updated(props: PropertyValues) { + super.updated(props); + this.disabled ? this.setAttribute('tabindex', '-1') : this.removeAttribute('tabindex'); + manageScreenReaderElements(this, this.expanded); + } +} diff --git a/packages/core/src/navigation/navigation-start.element.scss b/packages/core/src/navigation/navigation-start.element.scss new file mode 100644 index 0000000000..f8122853da --- /dev/null +++ b/packages/core/src/navigation/navigation-start.element.scss @@ -0,0 +1,48 @@ +/*! + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +@import './../styles/tokens/generated/index'; +@import './../styles/mixins/mixins'; + +:host { + --color: inherit; + --line-height: inherit; + --font-size: inherit; + --font-weight: inherit; + --text-transform: capitalize; +} + +:host(:hover), +:host([_has-focus]) { + background: #{$cds-alias-object-interaction-background-hover}; +} + +.private-host { + color: var(--color); + background: inherit; + border: 0; + font-size: var(--font-size); + font-weight: var(--font-weight); + height: var(--line-height); + outline: none; + text-transform: var(--text-transform); + + &:hover { + cursor: pointer; + } +} + +.icon-slot { + color: var(--color); +} + +::slotted(span) { + color: var(--color); +} + +:host([is-group-start][_active]:not([_expanded])) { + background: #{$cds-alias-object-interaction-background-active}; +} diff --git a/packages/core/src/navigation/navigation-start.element.spec.ts b/packages/core/src/navigation/navigation-start.element.spec.ts new file mode 100644 index 0000000000..7da9151668 --- /dev/null +++ b/packages/core/src/navigation/navigation-start.element.spec.ts @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html } from 'lit'; +import { createTestElement, removeTestElement, componentIsStable } from '@cds/core/test'; +import { CdsNavigation, CdsNavigationGroup, CdsNavigationStart } from './index.js'; +import '@cds/core/navigation/register.js'; + +import { CdsIcon } from '@cds/core/icon/icon.element.js'; + +describe('cds-navigation-start', () => { + let component: CdsNavigationStart; + let element: HTMLElement; + let icon: CdsIcon; + + beforeEach(async () => { + element = await createTestElement(html` + + Start Element + + `); + + component = element.querySelector('cds-navigation-start'); + icon = component.shadowRoot.querySelector('cds-icon'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('should create navigation start component', async () => { + expect(component).toBeTruthy(); + }); + + // is group start: true + it('has an id', async () => { + await componentIsStable(component); + expect(component.hasAttribute('id')).toBe(true); + }); + + it('has a focus element', async () => { + await componentIsStable(component); + expect(component.focusElement.tagName).toBe('BUTTON'); + }); + + it('can be focused', async () => { + await componentIsStable(component); + expect(component.getAttribute('_has-focus')).toBeFalsy(); + component.hasFocus = true; + await componentIsStable(component); + expect(component.getAttribute('_has-focus')).toBe(''); + }); + + it('wraps text inside a span element', async () => { + await componentIsStable(component); + const wrappedText = component.querySelector('[cds-navigation-sr-text]'); + expect(wrappedText.textContent.trim()).toEqual('Start Element'); + }); + + it('manages screen reader text', async () => { + await componentIsStable(component); + const wrappedSpan = component.querySelector('span[cds-layout]'); + expect(wrappedSpan.getAttribute('cds-layout')).toBe('display:screen-reader-only'); + component.expandedRoot = true; + await componentIsStable(component); + expect(wrappedSpan.getAttribute('cds-layout')).toBeNull(); + }); + + it('has the correct icon size for expanded attribute', async () => { + await componentIsStable(component); + expect(icon.getAttribute('size')).toBe('sm'); + component.expandedRoot = true; + await componentIsStable(component); + expect(icon.getAttribute('size')).toBe('sm'); + }); + + describe('supports user overrides', () => { + let element: HTMLElement; + let icon: CdsIcon; + + beforeEach(async () => { + element = await createTestElement(html` + + Start Element + + + `); + + component = element.querySelector('cds-navigation-start'); + icon = component.querySelector('cds-icon'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('and projects icons with cds-navigation-start-icon attribute', async () => { + expect(icon.getAttribute('shape')).toBe('cog'); + }); + }); + + describe('at navigation group level', () => { + let component: CdsNavigationGroup; + let start: CdsNavigationStart; + let icon: CdsIcon; + let element: HTMLElement; + + beforeEach(async () => { + element = await createTestElement(html` + + + Start Element + + + `); + + component = element.querySelector('cds-navigation-group'); + start = component.querySelector('cds-navigation-start'); + icon = start.shadowRoot.querySelector('cds-icon'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('is a groupStart', () => { + expect(start.isGroupStart).toBeTruthy(); + }); + + it('associates navigationGroupId', () => { + const itemWrapper = component.shadowRoot.querySelector('div.group-items-wrapper'); + expect(start.navigationGroupId).toEqual(itemWrapper.getAttribute('aria-labelledby')); + const groupButton = start.shadowRoot.querySelector('button'); + expect(start.navigationGroupId).toEqual(groupButton.getAttribute('id')); + }); + + it('provides a default icon with angle shape', async () => { + const icon = start.shadowRoot.querySelector('cds-icon'); + expect(icon.getAttribute('shape')).toBe('angle'); + }); + + it('should size icons to correct sizes for expanded state', async () => { + await componentIsStable(component); + expect(start.expandedRoot).toBeFalsy(); + expect(icon.getAttribute('size')).toBe('xs'); + start.expandedRoot = true; + await componentIsStable(component); + expect(icon.getAttribute('size')).toBe('sm'); + }); + + it('uses the correct icon', async () => { + await componentIsStable(component); + expect(icon.getAttribute('shape')).toBe('angle'); + }); + }); + + describe('at navigation root level', () => { + let component: CdsNavigation; + let start: CdsNavigationStart; + let element: HTMLElement; + + beforeEach(async () => { + element = await createTestElement(html` + + + Start Element + + + `); + + component = element.querySelector('cds-navigation'); + start = component.querySelector('cds-navigation-start'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('not isGroupStart', async () => { + await componentIsStable(component); + expect(start.isGroupStart).toBeFalsy(); + }); + + it('provides a default icon with angle-double shape', async () => { + const icon = start.shadowRoot.querySelector('cds-icon'); + expect(icon.getAttribute('shape')).toBe('angle-double'); + }); + }); +}); diff --git a/packages/core/src/navigation/navigation-start.element.ts b/packages/core/src/navigation/navigation-start.element.ts new file mode 100644 index 0000000000..81990cbb6e --- /dev/null +++ b/packages/core/src/navigation/navigation-start.element.ts @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html, LitElement, PropertyValues } from 'lit'; +import { + baseStyles, + createId, + Directions, + i18n, + I18nService, + property, + querySlot, + querySlotAll, + spanWrapper, + state, +} from '@cds/core/internal'; +import styles from './navigation-start.element.scss'; +import { getToggleIconDirection, manageScreenReaderElements, NAVIGATION_TEXT_WRAPPER } from './utils/index.js'; +import { CdsIcon } from '@cds/core/icon/icon.element.js'; +import { FocusableItem, NavigationFocusState } from './interfaces/navigation.interfaces.js'; + +export const CdsNavigationStartTagName = 'cds-navigation-start'; + +/** + * Web component navigation. + * + * ```typescript + * import '@cds/core/navigation/register.js'; + * ``` + * + * ```html + * Start text + * ``` + * @beta + * @element cds-navigation-start + * @cssprop --color: inherit + * @cssprop --line-height: inherit + * @cssprop --font-size: inherit + * @cssprop --font-weight: inherit + * @slot + * @slot cds-navigation-start-icon - customize the default start toggle icon + */ +export class CdsNavigationStart extends LitElement implements FocusableItem { + @i18n() i18n = I18nService.keys.navigation; + + /** + * @desc + * Synced down from the root navigation element. Determines if the vertical navigation is wide or narrow. + */ + @property({ type: Boolean }) + expandedRoot = false; + + /** + * @desc + * Is set to true by the root cds-navigation element when the instance is focused. + */ + @state({ type: Boolean, reflect: true }) + hasFocus: NavigationFocusState = false; + + /** + * @desc + * The value is synced down from the root cds-navigation element. + */ + @property({ type: Boolean, reflect: true }) + isGroupStart = false; + + @property({ type: String }) + navigationGroupId: string; + + /** + * @desc info synced down from group element and used in css to set proper bg color if a group has an active item and is not expanded + * + * @private + */ + @state({ type: Boolean, reflect: true }) + active = false; + + /** + * @desc + * Describes the groups expanded state + * + * @private + */ + @state({ type: Boolean, reflect: true }) + expanded = false; + + /** + * @desc + * Start element must find the button in firstUpdated. When the arrow keys navigation to the the util fn setFocus + * calls the native focus method. + * + * @private + */ + focusElement: HTMLButtonElement; + + @querySlot('[cds-navigation-start-icon]', { assign: 'cds-icon-slot' }) + protected startIcon: CdsIcon; + + @querySlotAll('[cds-navigation-sr-text]') + itemText: NodeListOf; + + connectedCallback() { + super.connectedCallback(); + if (!this.id) { + this.id = createId(); + } + } + + firstUpdated(props: PropertyValues) { + super.firstUpdated(props); + const button = this.shadowRoot?.querySelector('button'); + if (button) { + this.focusElement = button; + } + this.handleStartButtonText(); + } + + private handleStartButtonText() { + spanWrapper(this.childNodes); + // get the projected text now wrapped in a span and add the sr attribute. + this.querySelector('span')?.setAttribute(NAVIGATION_TEXT_WRAPPER, ''); + } + + render() { + return html` +
+ +
+ `; + } + + static get styles() { + return [baseStyles, styles]; + } + + get toggleIconDirection(): Directions { + return getToggleIconDirection(this); + } + + updated(props: PropertyValues) { + super.updated(props); + manageScreenReaderElements(this, this.expandedRoot); + } +} diff --git a/packages/core/src/navigation/navigation.element.scss b/packages/core/src/navigation/navigation.element.scss new file mode 100644 index 0000000000..ec5dabaa60 --- /dev/null +++ b/packages/core/src/navigation/navigation.element.scss @@ -0,0 +1,67 @@ +@import './../styles/tokens/generated/index'; +@import './../styles/mixins/mixins'; + +:host { + --animation-duration: #{$cds-global-animation-duration-secondary}; + --animation-easing: #{$cds-global-animation-easing-primary}; + --background: #{$cds-alias-object-container-background}; + --color: #{$cds-global-typography-color-500}; + --collapsed-width: #{$cds-global-layout-space-xl}; + --expanded-width: calc(#{$cds-global-space-6} * 20); + --line-height: #{$cds-global-space-11}; + --font-size: #{$cds-global-typography-font-size-4}; + --font-weight: #{$cds-global-typography-font-weight-regular}; + --letter-spacing: #{$cds-global-typography-body-letter-spacing}; + --nested-padding: #{$cds-global-space-4}; + --padding: 0 #{$cds-global-space-4}; + // Note: putting height property on .private host doesn't affect component behavior in DOM. + // We need to inherit the explicit height of the parent container to get scrolling / cds-layout + // behaviors to work. + height: inherit; +} + +:host(:focus) { + outline: none !important; +} + +:host([expanded]) { + .private-host { + width: var(--expanded-width); + min-width: var(--expanded-width); + } +} + +:host([cds-motion='off']) { + width: var(--collapsed-width); +} + +:host([cds-motion='off'][expanded]) { + width: var(--expanded-width); +} + +:host([cds-motion][_cds-animation-status='ready']:not([cds-motion='off'])) { + width: var(--collapsed-width); +} + +:host([cds-motion][expanded][_cds-animation-status='ready']:not([cds-motion='off'])) { + width: var(--expanded-width); + transform: none; +} + +.private-host { + color: var(--color); + background: var(--background); + height: inherit; + width: var(--collapsed-width); + min-width: var(--collapsed-width); +} + +.navigation-body { + height: 100%; +} + +.navigation-body-wrapper { + width: 100%; + height: 100%; + overflow: auto; +} diff --git a/packages/core/src/navigation/navigation.element.spec.ts b/packages/core/src/navigation/navigation.element.spec.ts new file mode 100644 index 0000000000..b0d5579efc --- /dev/null +++ b/packages/core/src/navigation/navigation.element.spec.ts @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html } from 'lit'; +import { createTestElement, removeTestElement, componentIsStable } from '@cds/core/test'; +import { CdsNavigation, CdsNavigationGroup, CdsNavigationItem, CdsNavigationStart } from './index.js'; +import '@cds/core/navigation/register.js'; +import Spy = jasmine.Spy; + +describe('cds-navigation', () => { + let component: CdsNavigation; + let element: HTMLElement; + let click: MouseEvent; + + beforeEach(async () => { + element = await createTestElement(html` + + Root start + Root item + + `); + component = element.querySelector('cds-navigation'); + click = new MouseEvent('click'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('should create a navigation component', async () => { + await componentIsStable(component); + expect(component).toBeTruthy(); + }); + + it('emits the expandedChange event', async () => { + await componentIsStable(component); + let count = 0; + const expandedSpy: Spy = jasmine.createSpy('expandedChange').and.callFake(() => { + count++; + }); + component.addEventListener('expandedChange', expandedSpy); + const startEle = component.querySelector('cds-navigation-start'); + startEle.dispatchEvent(click); + await componentIsStable(component); + expect(count).toBe(1); + }); + + describe('should handle events', () => { + let component: CdsNavigation; + let element: HTMLElement; + let groupStart: CdsNavigationStart; + let rootStart: CdsNavigationStart; + let group: CdsNavigationGroup; + + function initFocus(element: any) { + const ie = new KeyboardEvent('keydown', { + code: 'tab', + key: 'tab', + bubbles: true, + }); + element.dispatchEvent(ie); + element.focus(); + } + + function arrowDownEvent(element: any) { + const ade = new KeyboardEvent('keydown', { + code: 'ArrowDown', + key: 'ArrowDown', + bubbles: true, + }); + element.dispatchEvent(ade); + } + + // TODO: modify the arrow events to take starts or items for existing tests. + function arrowUpEvent(element: any) { + const aue = new KeyboardEvent('keydown', { + code: 'ArrowUp', + key: 'ArrowUp', + bubbles: true, + }); + element.dispatchEvent(aue); + } + + function arrowLeftEvent(element: any) { + const ale = new KeyboardEvent('keydown', { + code: 'ArrowLeft', + key: 'ArrowLeft', + bubbles: true, + }); + element.dispatchEvent(ale); + } + + function arrowRightEvent(element: any) { + const are = new KeyboardEvent('keydown', { + code: 'ArrowRight', + key: 'ArrowRight', + bubbles: true, + }); + element.dispatchEvent(are); + } + + function homeEvent(element: any) { + const he = new KeyboardEvent('keydown', { + code: 'Home', + key: 'Home', + bubbles: true, + }); + element.dispatchEvent(he); + } + + function endEvent(element: any) { + const ee = new KeyboardEvent('keydown', { + code: 'End', + key: 'End', + bubbles: true, + }); + element.dispatchEvent(ee); + } + + beforeEach(async () => { + element = await createTestElement(html` + + Root start + Root item + + + + Group start + + Group item + + + `); + component = element.querySelector('cds-navigation'); + group = component.querySelector('cds-navigation-group'); + groupStart = component.querySelector('cds-navigation-start#groupStart'); + ``; + rootStart = component.querySelector('cds-navigation-start#rootStart'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('and remove focus from currentActiveItem after a blur event', async () => { + await componentIsStable(component); + initFocus(component); + expect(rootStart.hasFocus).toBeTruthy(); + component.dispatchEvent(new Event('blur')); + await componentIsStable(component); + expect(rootStart.hasFocus).toBeFalsy(); + }); + + it('and set the aria-activedescendent and currentActiveItem after cds-navigation element is focused', async () => { + await componentIsStable(component); + // nothing before component is focused on via tab key + expect(component.ariaActiveDescendant).toBeUndefined(); + initFocus(component); + await componentIsStable(component); + // first item when it is focused on + const activeEle = component.querySelector(':scope > cds-navigation-start'); + expect(component.ariaActiveDescendant).toBe(activeEle.id); + const currentActiveItem = component.currentActiveItem; + expect(component.ariaActiveDescendant).toBe(currentActiveItem.id); + }); + + it('and set the next focusable element after arrow down', async () => { + await componentIsStable(component); + initFocus(component); + // next item after root start element + const itemEle = component.querySelector('cds-navigation-item'); + arrowDownEvent(component); + await componentIsStable(component); + expect(component.ariaActiveDescendant).toBe(itemEle.id); + }); + + it('and set the previous focusable element after arrow up', async () => { + await componentIsStable(component); + initFocus(component); // focus is on first item (root start) + await componentIsStable(component); + arrowUpEvent(component); + await componentIsStable(component); + // expect the group start element to be focused b/c previous wraps around to the tail + const groupStart = component.querySelector('cds-navigation-group > cds-navigation-start'); + expect(component.ariaActiveDescendant).toBe(groupStart.id); + }); + + it('and expand the root navigation after arrow right', async () => { + component.addEventListener('expandedChange', event => { + if (event.returnValue) { + component.setAttribute('expanded', ''); + } + }); + await componentIsStable(component); + initFocus(component); // focus is on first focusable element (root start) + await componentIsStable(component); + arrowRightEvent(component); + await componentIsStable(component); + expect(component.expanded).toBeTruthy(); + }); + + it('and collapse the root navigation after arrow left event', async () => { + component.addEventListener('expandedChange', event => { + if (!event.returnValue) { + component.removeAttribute('expanded'); + } + }); + await componentIsStable(component); + initFocus(component); // focus is on first focusable element (root start) + await componentIsStable(component); + arrowRightEvent(rootStart); // expands the root element + await componentIsStable(component); + arrowLeftEvent(rootStart); + await componentIsStable(component); + expect(component.expanded).toBeFalsy(); + expect(component.getAttribute('expanded')).toBeNull(); + }); + + it('and expand the group navigation after arrow right event', async () => { + group.addEventListener('expandedChange', event => { + if (event.returnValue) { + group.setAttribute('expanded', ''); + } + }); + await componentIsStable(component); + initFocus(component); // focus is on first focusable element (root start) + arrowDownEvent(component); // rootItem + arrowDownEvent(component); // group + arrowRightEvent(groupStart); + await componentIsStable(component); + expect(group.expanded).toBeTruthy(); + }); + + it('and collapse navigation groups after left arrow keyboard event is triggered on and expanded group start element', async () => { + group.addEventListener('expandedChange', event => { + if (event.returnValue) { + group.setAttribute('expanded', ''); + } else if (!event.returnValue) { + group.removeAttribute('expanded'); + } + }); + + await componentIsStable(component); + initFocus(component); // focus is on first focusable element (root start) + arrowDownEvent(component); // rootItem + arrowDownEvent(component); // group + await componentIsStable(component); + arrowLeftEvent(groupStart); // this should collapse the group + await componentIsStable(component); + expect(group.expanded).toBeFalsy(); + }); + + it('and move focus to first/last focusable items after end/home key events', async () => { + await componentIsStable(component); + initFocus(component); // focus is on first focusable element (root start) + await componentIsStable(component); + endEvent(component); // focus should be on the first focusable item (rootStart) + await componentIsStable(component); + expect(component.ariaActiveDescendant).toBe(groupStart.id); + homeEvent(component); + await componentIsStable(component); + expect(component.ariaActiveDescendant).toBe(rootStart.id); + }); + + it('and have a currentActiveItem', async () => { + expect(component.currentActiveItem).toBeFalsy(); + await componentIsStable(component); + initFocus(component); // focus is on first focusable element (root start) + await componentIsStable(component); + expect(component.currentActiveItem).toBeTruthy(); + }); + + it('for group expandedChange emissions', async () => { + await componentIsStable(component); + initFocus(component); + group.expandedChange.emit(true); + expect(component.currentActiveItem.hasFocus).toBeTruthy(); + }); + }); + + describe('layouts', () => { + let component: CdsNavigation; + let element: HTMLElement; + let rootStart: CdsNavigationStart; + + beforeEach(async () => { + element = await createTestElement(html` + + Root item + + Group start + Group item + + +
+ + nav end slot + + + + `); + component = element.querySelector('cds-navigation'); + rootStart = component.querySelector('cds-navigation > cds-navigation-start'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('handles templates without rootNavigationStart elements', function () { + expect(rootStart).toBeNull(); + }); + + it('handles the footer template with navigation-end slot', async () => { + const slottedContainer = component.shadowRoot.querySelector('.navigation-end'); + const slottedItem = slottedContainer.querySelector( + 'cds-navigation-item[slot="cds-navigation-end"]' + ); + expect(slottedContainer).toBeDefined(); + expect(slottedItem).toBeDefined(); + }); + }); + + describe('syncs props', () => { + let component: CdsNavigation; + let element: HTMLElement; + + beforeEach(async () => { + element = await createTestElement(html` + + Root start + Root item + + Group start + Group item + + + `); + component = element.querySelector('cds-navigation'); + }); + + afterEach(() => { + removeTestElement(element); + }); + + it('to navigationGroupItems', async () => { + await componentIsStable(component); + const item = component.querySelector(':scope > cds-navigation-group > cds-navigation-item'); + expect(item.groupItem).toBe(true); + }); + + it('to navigationItemRefs', async () => { + await componentIsStable(component); + const itemRefs = component.querySelectorAll('cds-navigation-item'); + itemRefs.forEach(item => { + expect(item.expanded).toBe(component.expanded); + }); + }); + + it('to navigationStartRefs', async () => { + await componentIsStable(component); + const startRefs = component.querySelectorAll('cds-navigation-start'); + startRefs.forEach(start => { + expect(start.expandedRoot).toBe(component.expandedRoot); + }); + }); + + it('to rootNavigationStart', async () => { + await componentIsStable(component); + const rootStart = component.querySelector(':scope > cds-navigation-start'); + expect(rootStart.expanded).toBe(component.expanded); + }); + + it('to rootNavigationItems', async () => { + await componentIsStable(component); + const rootItems = component.querySelectorAll(':scope > cds-navigation-item'); + rootItems.forEach(item => { + expect(item.expanded).toBe(component.expanded); + }); + }); + }); +}); diff --git a/packages/core/src/navigation/navigation.element.ts b/packages/core/src/navigation/navigation.element.ts new file mode 100644 index 0000000000..fe8bec6a6f --- /dev/null +++ b/packages/core/src/navigation/navigation.element.ts @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html, LitElement, PropertyValues } from 'lit'; +import { + Animatable, + animate, + baseStyles, + event, + EventEmitter, + i18n, + I18nService, + state, + isVisible, + onKey, + property, + querySlot, + querySlotAll, + reverseAnimation, + setAttributes, + syncProps, + syncPropsForAllItems, +} from '@cds/core/internal'; +import styles from './navigation.element.scss'; + +import { + DEFAULT_NAVIGATION_LAYOUT, + FocusableElement, + getNextFocusElement, + getPreviousFocusElement, + removeFocus, + setFocus, + visibleElement, +} from './utils/index.js'; +import { CdsNavigationGroup } from './navigation-group.element.js'; +import { CdsNavigationStart } from './navigation-start.element.js'; +import { CdsNavigationItem } from './navigation-item.element.js'; +import { CdsDivider } from '@cds/core/divider/index.js'; +import { AnimationNavigationOpenName } from '../internal/motion/animations/cds-navigation-open.js'; + +export const CdsNavigationTagName = 'cds-navigation'; + +/** + * ```typescript + * import '@cds/core/navigation/register.js'; + * ``` + * + * ```html + * + * Home + * Account + * + * ``` + * + * @beta + * @element cds-navigation + * @event expandedChange - notify when the user has clicked the navigation expand/collapse button + * @cssprop --animation-duration + * @cssprop --animation-easing + * @cssprop --background + * @cssprop --collapsed-width + * @cssprop --expanded-width + * @cssprop --font-size + * @cssprop --font-weight + * @cssprop --letter-spacing + * @cssprop --line-height + * @cssprop --nested-padding + * @cssprop --padding + * @slot + * @slot - cds-navigation-substart - project content below the navigation toggle button + * @slot - cds-navigation-end - project content below the scrollable section + */ +@animate({ + expanded: { + true: AnimationNavigationOpenName, + false: reverseAnimation(AnimationNavigationOpenName), + }, +}) +export class CdsNavigation extends LitElement implements Animatable { + expandedRoot = false; + + @property({ type: String }) + cdsMotion = 'on'; + + @event() + protected expandedChange: EventEmitter; + + @event() + cdsMotionChange: EventEmitter; + + /** + * This is used to sync down the information to this.navigationGroupItems + * + * @type { boolean } + * @protected + */ + @state({ type: Boolean }) + protected groupItem = true; + + /** + * Set and update the aria-active descended value onto the navigation. + */ + @property({ type: String, reflect: true, attribute: 'aria-activedescendant' }) + ariaActiveDescendant: any; + + /** + * + * Vertical navigation elements can be either wide or narrow. Expanded indicates it should be wide. + * When navigation is wide cds-navigation-start button elements and cds-navigation-item a elements display + * text. When it is narrow they do not (consumer should provide an icon that stays visible). + + * @type {boolean} + */ + @property({ type: Boolean }) + expanded = false; + + @i18n() i18n = I18nService.keys.navigation; + + /** + * The end slot that items can be projected into with slot="cds-navigation-end" + */ + @querySlot('[slot="cds-navigation-end"]', { assign: 'cds-navigation-end' }) + protected navigationEnd: HTMLElement; + + /** + * This slot query is used to identify and manage all focusable elements needed for arrow key navigation + * TODO: How to add in forms selector attribute and other things that are not FocusableElements like I use here + * tbd - I don;'t have an answer yet. + */ + @querySlotAll('cds-navigation-start, cds-navigation-item:not([disabled])') + protected allNavigationElements: NodeListOf; + + /** + * Get references to all of the start elements so they can be passed state when updates are made. + */ + @querySlotAll('cds-navigation-start') + protected navigationStartRefs: NodeListOf; + /** make navigation-body default and eliminate extra assigns **? + /** + * query for cds-divider and project into navigation-body slot. + */ + @querySlotAll(':scope > cds-divider') + protected rootDividers: NodeListOf; + + /** + * query for root level groups and project them into the navigation-body slot. + */ + @querySlotAll(':scope > cds-navigation-group') + protected rootNavigationGroups: NodeListOf; + + /** + * query for root level items and project them into the navigation-body slot. + */ + @querySlotAll(':scope > cds-navigation-item') + protected rootNavigationItems: NodeListOf; + + /** + * query for the root level start items and project them into the navigation-start slot. + */ + @querySlot(':scope > cds-navigation-start', { assign: 'navigation-start' }) + protected rootNavigationStart: CdsNavigationStart; + + /** + * query for items inside a cds-navigation-group, used to pass state down + */ + @querySlotAll(':scope > cds-navigation-group > cds-navigation-item') + protected navigationGroupItems: NodeListOf; + + /** + * query for all cds-navigation elements, used to pass state down + */ + @querySlotAll('cds-navigation-item') + protected navigationItemRefs: NodeListOf; + + /** + * query for all groups (including any nested groups), used ot pass state down + */ + @querySlotAll('cds-navigation-group') + protected navigationGroupRefs: NodeListOf; + + private initAriaActiveDescendant() { + /** + * If there is a currentActiveItem, focus on that + * If there is not a currentActiveItem focus on the first visible element + */ + const focusElement = this.currentActiveItem ? this.currentActiveItem : this.allNavigationElements[0]; + setFocus(focusElement); + this.ariaActiveDescendant = focusElement.id; + } + + /** + * Rules for keyboard handling logic: + * + * 1. when cds-navigation element receives focus if there is already an active focus item, + * set focus on it, else set focus on first focusable item + * 2. arrow key down sets focus on the next focusable item, if last item it moves focus to first focusable item + * 3. arrow key up sets focus on the previous focusable item, if first it moves focus to the last focusable item + * 4. arrow key left on a cds-navigation-item inside cds-navigation-group will put focus on the cds-navigation-start + * button for the group + * 5. arrow key left on a cds-navigation-start element inside a cds-navigation-group will emit the groups + * expandedChange event + * 6. arrow key right on a non expanded cds-navigation-group will emit the groups expandedChange event + * 7. arrow key left on a root cds-navigation-start element will fire the cds-navigation expandedChange event if + * the cds-navigation element is expanded + * 8. arrow key right on a root cds-navigation-start element will fire the cds-navigation expandedChange event if + * the cds-navigation element is not expanded + * 9. home key will move focus to the first focusable item + * 10. end key will move focus to the last focusable item + * + * We may need a way to let consumers mark elements and include them in the focusable elements, not sure how yet + * + * @param event + * @private + */ + private keyboardNavigationHandler(event: KeyboardEvent) { + const focusableElements = Array.from(this.allNavigationElements).filter(visibleElement); + + onKey('arrow-down', event, () => { + if (this.currentActiveItem) { + removeFocus(this.currentActiveItem); + const next = getNextFocusElement(this.currentActiveItem, focusableElements); + this.ariaActiveDescendant = next.id; + setFocus(next); + // event.preventDefault(); // needed for when when overflow scrolling is enabled + } + }); + + onKey('arrow-up', event, () => { + if (this.currentActiveItem) { + removeFocus(this.currentActiveItem); + const next = getPreviousFocusElement(this.currentActiveItem, focusableElements); + this.ariaActiveDescendant = next.id; + setFocus(next); + // event.preventDefault(); // needed for when when overflow scrolling is enabled + } + }); + + onKey('arrow-right', event, () => { + const groupParent = this.currentActiveItem?.closest('cds-navigation-group'); + + if (groupParent && !groupParent.expanded) { + groupParent.expandedChange.emit(!groupParent.expanded); + return; + } + + if (!groupParent) { + this.toggle(); + return; + } + }); + + onKey('arrow-left', event, () => { + const groupParent = this.currentActiveItem?.closest('cds-navigation-group'); + if (!groupParent) { + this.toggle(); + return; + } + if (this.currentActiveItem?.tagName === 'CDS-NAVIGATION-ITEM' && !!groupParent) { + const groupStartElement = groupParent?.querySelector('cds-navigation-start'); + removeFocus(this.currentActiveItem as FocusableElement); + this.ariaActiveDescendant = groupStartElement?.id; + setFocus(groupStartElement as FocusableElement); + return; + } + + if (groupParent && groupParent.expanded) { + groupParent.expandedChange.emit(!groupParent.expanded); + return; + } + }); + + onKey('home', event, () => { + if (this.currentActiveItem) { + removeFocus(this.currentActiveItem); + const home = focusableElements[0]; + this.ariaActiveDescendant = home.id; + setFocus(home); + } + }); + + onKey('end', event, () => { + if (this.currentActiveItem) { + removeFocus(this.currentActiveItem); + const end = focusableElements[focusableElements.length - 1]; + this.ariaActiveDescendant = end.id; + setFocus(end); + } + }); + } + + private toggle() { + this.expandedChange.emit(!this.expanded); + } + + get currentActiveItem() { + return this.visibleChildren.find(c => c.id === this.ariaActiveDescendant); + } + + protected get endTemplate() { + return this.navigationEnd + ? html` + + ` + : ''; + } + + protected get startTemplate() { + let returnHTML; + + this.rootNavigationStart + ? (returnHTML = html` + + `) + : (returnHTML = ''); + + return returnHTML; + } + + protected get visibleChildren(): FocusableElement[] { + return Array.from(this.allNavigationElements).filter(n => isVisible(n)); + } + + addStartEventListener() { + // This is controlled by the slotchange event on the navigation-start slot + // Make sure we reattach the click handler for toggle if consumer changes the start element (e.g *ngIf) + if (this.rootNavigationStart) { + this.rootNavigationStart.addEventListener('click', this.toggle.bind(this)); + } + } + + firstUpdated() { + // set all visible navigation elements to tabindex = -1 + this.allNavigationElements.forEach(item => { + setAttributes(item, ['tabindex', '-1']); + }); + } + + connectedCallback() { + super.connectedCallback(); + this.tabIndex = 0; + this.addEventListener('focus', this.initAriaActiveDescendant); + this.addEventListener('keydown', this.keyboardNavigationHandler); + this.addEventListener('blur', () => { + if (this.currentActiveItem && this.currentActiveItem.hasFocus) { + removeFocus(this.currentActiveItem as FocusableElement); + } + }); + this.addEventListener('expandedChange', this.setActiveItemFocus); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.rootNavigationStart) { + this.rootNavigationStart.removeEventListener('click', this.toggle.bind(this)); + } + } + + private setActiveItemFocus(event: Event) { + if (this.currentActiveItem && event.target instanceof CdsNavigationGroup) { + setFocus(this.currentActiveItem); + } + } + + render() { + return html``; + } + + updated(props: PropertyValues) { + super.updated(props); + + if (props.has('expanded')) { + this.expandedRoot = this.expanded; + } + + this.updateChildrenProps(); + } + + updateChildrenProps() { + if (this.navigationGroupItems) { + syncPropsForAllItems(Array.from(this.navigationGroupItems), this, { groupItem: true }); + } + + if (this.navigationItemRefs) { + syncPropsForAllItems(Array.from(this.navigationItemRefs), this, { + expanded: true, + }); + } + + if (this.navigationStartRefs) { + syncPropsForAllItems(Array.from(this.navigationStartRefs), this, { + expandedRoot: true, + }); + } + + if (this.rootNavigationStart) { + syncProps(this.rootNavigationStart, this, { + expanded: this.expanded, + }); + } + + if (this.rootNavigationItems.length > 0) { + syncPropsForAllItems(Array.from(this.rootNavigationItems), this, { + expanded: this.expanded, + }); + } + + if (this.navigationGroupRefs.length > 0) { + syncPropsForAllItems(Array.from(this.navigationGroupRefs), this, { + layout: true, + }); + } + } + + static get styles() { + return [baseStyles, styles]; + } +} diff --git a/packages/core/src/navigation/navigation.stories.mdx b/packages/core/src/navigation/navigation.stories.mdx new file mode 100644 index 0000000000..0ce1d49305 --- /dev/null +++ b/packages/core/src/navigation/navigation.stories.mdx @@ -0,0 +1,117 @@ +import { Meta, Props, Story, Preview } from '@storybook/addon-docs/blocks'; +import { html, LitElement } from 'lit'; +import '@cds/core/navigation/register.js'; + + + + + + This component or utility is offered as a preview. This means we are currently working on it and seeking feedback. + Please be aware that this component or utility may have breaking changes before we finish working on it. + + + +# Navigation + +A sound navigation layout offers a high degree of discoverability and feedback, letting users know where they are at all +times and ensuring they can easily get to where they want to go. + +Navigation enables discoverability and feedback from users. It lets them know where they are at all times and ensures +they can easily get where they want to go. + +Core navigation provides vertical navigation elements with the following features that can be used to organize +application navigation patterns before users to find application content. + +## Installation + +To use the navigation component, import the component in your JavaScript. + +```typescript +import '@cds/core/navigation/register.js'; +``` + +```html + + + About + + + Account + + + Settings + + +``` + +## Basic + + + + + +## Icon Link + + + + + +## Collapsible Navigation + + + + + +## Vertical sub-start slot + + + + + +## Vertical align:bottom + +Note that when the parent of vertical navigation has a height, it is implemented to expand to the height of its parent. +This allows consumers to place cds-layout token align:bottom on an item and push it to the bottom. + + + + + +## Vertical end + +Note that when the parent of vertical navigation has a height, it is implemented to expand to the height of its parent. +This give consumers a _box_ to place content at the bottom of the navigation element. + + + + + +## Vertical navigation group + + + + + +## Dark Theme + + + + + +## API + +### cds-navigation + + + +### cds-navigation-group + + + +### cds-navigation-start + + + +### cds-navigation-item + + diff --git a/packages/core/src/navigation/navigation.stories.ts b/packages/core/src/navigation/navigation.stories.ts new file mode 100644 index 0000000000..77ea588912 --- /dev/null +++ b/packages/core/src/navigation/navigation.stories.ts @@ -0,0 +1,585 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import '@cds/core/navigation/register.js'; +import { getElementStorybookArgTypes } from '@cds/core/internal'; +import customElements from '../../dist/core/custom-elements.json'; +import { html } from 'lit'; +import { CdsNavigation } from '@cds/core/navigation'; +import { CdsNavigationGroup } from '@cds/core/navigation'; +import { CdsIcon } from '@cds/core/icon/icon.element.js'; + +export default { + title: 'Stories/Navigation', + component: 'cds-navigation', + argTypes: getElementStorybookArgTypes('cds-navigation', customElements), + parameters: { + options: { showPanel: true }, + design: { + type: 'figma', + url: 'https://www.figma.com/file/v2mkhzKQdhECXOx8BElgdA/Clarity-UI-Library---light-2.2.0?node-id=22%3A0', + }, + }, +}; + +// export function API(args: any) { +// return html` +//
+// +// Navigation toggle button +// Root item one +// Root item two +// Root item three +// Root item four +// +// Group toggle button +// Group item one +// Group item two +// Group item three +// Group item four +// +// +// +// I'm tabbable content +// +//
+// `; +// } + +export function verticalBasic() { + return html` + + `; +} + +export function verticalIconLink() { + return html` + + `; +} + +export function collapsibleVerticalNavigation() { + const onExpandChange = { + handleEvent(e: Event) { + const navigation = e.target as CdsNavigation; + navigation.expanded = !navigation.expanded; + const customIcon = document.getElementById('custom-icon') as CdsIcon; + navigation.expanded ? (customIcon.shape = 'eye-hide') : (customIcon.shape = 'eye'); + }, + }; + + return html` + + `; +} + +export function verticalBasicSubStart() { + return html` + + `; +} + +export function verticalAlignBottom() { + return html` + + `; +} + +export function verticalEnd() { + return html` + + `; +} + +export function navigationGroups() { + const onExpandChange = { + handleEvent(e: Event) { + const navigation = e.target as CdsNavigation; + navigation.expanded = !navigation.expanded; + }, + }; + + const onExpandGroupChange = { + handleEvent(e: Event) { + const group = e.target as CdsNavigationGroup; + group.expanded = !group.expanded; + }, + }; + + return html` + + `; +} + +/** @website */ +export function darkTheme() { + return html` + + `; +} diff --git a/packages/core/src/navigation/register.ts b/packages/core/src/navigation/register.ts new file mode 100644 index 0000000000..45a1438d79 --- /dev/null +++ b/packages/core/src/navigation/register.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import '@cds/core/button/register.js'; +import '@cds/core/icon/register.js'; +import '@cds/core/divider/register.js'; +import { + AnimationNavigationGroupOpenConfig, + AnimationNavigationGroupOpenName, + AnimationNavigationOpenConfig, + AnimationNavigationOpenName, + ClarityMotion, + registerElementSafely, +} from '@cds/core/internal'; +import { CdsNavigation, CdsNavigationTagName } from './navigation.element.js'; +import { CdsNavigationGroup, CdsNavigationGroupTagName } from './navigation-group.element.js'; +import { CdsNavigationStart, CdsNavigationStartTagName } from './navigation-start.element.js'; +import { CdsNavigationItem, CdsNavigationItemTagName } from './navigation-item.element.js'; + +import { ClarityIcons } from '@cds/core/icon/icon.service.js'; +import { angleIcon } from '@cds/core/icon/shapes/angle.js'; +import { angleDoubleIcon } from '@cds/core/icon/shapes/angle-double.js'; + +ClarityIcons.addIcons(angleIcon); +ClarityIcons.addIcons(angleDoubleIcon); +ClarityMotion.add(AnimationNavigationGroupOpenName, AnimationNavigationGroupOpenConfig); +ClarityMotion.add(AnimationNavigationOpenName, AnimationNavigationOpenConfig); + +registerElementSafely(CdsNavigationTagName, CdsNavigation); +registerElementSafely(CdsNavigationGroupTagName, CdsNavigationGroup); +registerElementSafely(CdsNavigationStartTagName, CdsNavigationStart); +registerElementSafely(CdsNavigationItemTagName, CdsNavigationItem); + +declare global { + interface HTMLElementTagNameMap { + 'cds-navigation': CdsNavigation; + 'cds-navigation-group': CdsNavigationGroup; + 'cds-navigation-start': CdsNavigationStart; + 'cds-navigation-item': CdsNavigationItem; + } +} diff --git a/packages/core/src/navigation/utils/index.spec.ts b/packages/core/src/navigation/utils/index.spec.ts new file mode 100644 index 0000000000..c609d3517a --- /dev/null +++ b/packages/core/src/navigation/utils/index.spec.ts @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html } from 'lit'; +import { createTestElement, removeTestElement } from '@cds/core/test'; + +import { CdsNavigation, CdsNavigationGroup, CdsNavigationItem, CdsNavigationStart } from '@cds/core/navigation'; +import '@cds/core/navigation/register.js'; +import { + FocusableElement, + getNextFocusElement, + getPreviousFocusElement, + getToggleIconDirection, + manageScreenReaderElements, + removeFocus, + setFocus, + visibleElement, +} from './index.js'; + +describe('navigation internal utilities', () => { + let groupElement: CdsNavigationGroup; + let groupItem: CdsNavigationItem; + let groupInvisibleItem: CdsNavigationItem; + let groupStart: CdsNavigationStart; + let rootItem: CdsNavigationItem; + let rootStart: CdsNavigationStart; + let testElement: HTMLElement; + let component: CdsNavigation; + const testTemplate = html` + + Root start element + + Group start element + Group item one + Group item two + + + Group start element + Group item one + Group item two + + Root item one + Root item two + + `; + + beforeEach(async () => { + testElement = await createTestElement(testTemplate); + component = testElement.querySelector('cds-navigation'); + groupElement = component.querySelector('cds-navigation-group#groupElement'); + groupItem = component.querySelector('cds-navigation-item#groupItem'); + groupInvisibleItem = component.querySelector('cds-navigation-item#groupInvisibleItem'); + groupStart = component.querySelector('cds-navigation-start#groupStart'); + rootItem = component.querySelector('cds-navigation-item#rootItem'); + rootStart = component.querySelector('cds-navigation-start#rootStart'); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + describe('manage FocusableElements', () => { + it('and get the previous focusable element', () => { + const focusableElements: FocusableElement[] = [rootStart, rootItem, groupStart, groupItem]; + expect(getPreviousFocusElement(rootStart, focusableElements)).toBe(groupItem, 'with head call previous behavior'); + + expect(getPreviousFocusElement(groupStart, focusableElements)).toBe(rootItem, 'with standard previous behavior'); + }); + + it('and get the next focusable element', () => { + const focusableElements: FocusableElement[] = [rootStart, rootItem, groupStart, groupItem]; + + expect(getNextFocusElement(rootItem, focusableElements)).toBe(groupStart, 'with standard next behavior'); + + expect(getNextFocusElement(groupItem, focusableElements)).toBe(rootStart, 'with tail call next behavior'); + }); + + it('by identifying visible group start elements', async () => { + // group starts + expect(visibleElement(groupStart)).toBe(true); + groupElement.addEventListener('expandedChange', () => { + expect(visibleElement(groupStart)).toBe(false); + }); + groupElement.removeAttribute('expanded'); + }); + + it('determines elements that are not visible', () => { + expect(visibleElement(groupInvisibleItem)).toBe(false); + }); + + it('by identifying visible group item elements', () => { + // group starts + expect(visibleElement(groupItem)).toBe(true); + groupElement.addEventListener('expandedChange', () => { + expect(visibleElement(groupItem)).toBeNull(); + }); + groupElement.removeAttribute('expanded'); + }); + + it('by setting and removing focus', async () => { + // Start elements implement FocusableElement interface + setFocus(rootStart); + expect(rootStart.hasFocus).toBeTruthy('cds-navigation-start hasFocus state is broken'); + removeFocus(rootStart); + expect(rootStart.hasFocus).toBeFalsy('cds-navigation-start element is not focused'); + + // Item elements implement FocusableElement interface + setFocus(rootItem); + expect(rootItem.hasFocus).toBeTruthy('cds-navigation-item hasFocus state is broken'); + removeFocus(rootItem); + expect(rootItem.hasFocus).toBeFalsy('cds-navigation-item element is still focusable'); + }); + }); + + describe('will getToggleIconDirections', () => { + it('when cds-navigation-group element is collapsed', () => { + groupStart.expanded = false; + expect(getToggleIconDirection(groupStart)).toBe('right'); + }); + + it('when cds-navigation-group element is expanded', () => { + groupStart.expanded = true; + expect(getToggleIconDirection(groupStart)).toBe('down'); + }); + + it('when cds-navigation element is expanded', () => { + rootStart.expandedRoot = true; + expect(getToggleIconDirection(rootStart)).toBe('left'); + }); + + it('when cds-navigation element is collapsed', () => { + rootStart.expandedRoot = false; + expect(getToggleIconDirection(rootStart)).toBe('right'); + }); + }); + + describe('manageScreenReaderElements', () => { + let testSpan: HTMLSpanElement; + let wrapperElement: HTMLDivElement; + + beforeEach(() => { + wrapperElement = document.createElement('div'); + testSpan = document.createElement('span'); + testSpan.textContent = 'hello wrapper'; + wrapperElement.append(testSpan); + }); + + afterEach(() => { + removeTestElement(testSpan); + }); + + it('by adding and removing the screen reader only token', () => { + expect(testSpan.getAttribute('cds-layout')).toBeNull(); + manageScreenReaderElements(wrapperElement, false); + expect(testSpan.getAttribute('cds-layout')).toBe('display:screen-reader-only'); + manageScreenReaderElements(wrapperElement, true); + expect(testSpan.getAttribute('cds-layout')).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/navigation/utils/index.ts b/packages/core/src/navigation/utils/index.ts new file mode 100644 index 0000000000..3e7b8991fa --- /dev/null +++ b/packages/core/src/navigation/utils/index.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { Directions, setOrRemoveAttribute } from '@cds/core/internal'; +import { CdsNavigationItem, CdsNavigationStart } from '../index.js'; + +export const NAVIGATION_TEXT_WRAPPER = 'cds-navigation-sr-text'; +export const DEFAULT_NAVIGATION_LAYOUT = 'vertical'; + +export type FocusableElement = CdsNavigationItem | CdsNavigationStart; + +export function getNextFocusElement(current: FocusableElement, elements: FocusableElement[]): FocusableElement { + const idx = elements.indexOf(current); + return elements[idx + 1] ? elements[idx + 1] : elements[0]; +} + +export function getPreviousFocusElement(current: FocusableElement, elements: FocusableElement[]): FocusableElement { + const idx = elements.indexOf(current); + return elements[idx - 1] ? elements[idx - 1] : elements[elements.length - 1]; +} + +export function getToggleIconDirection(element: CdsNavigationStart): Directions { + if (element.isGroupStart) { + return element.expanded ? 'down' : 'right'; + } else { + return element.expandedRoot ? 'left' : 'right'; + } +} + +export function manageScreenReaderElements(element: HTMLElement, expandedRoot: boolean): void { + const span = element.querySelector('span'); + setOrRemoveAttribute(span, ['cds-layout', 'display:screen-reader-only'], () => { + return !expandedRoot; + }); +} + +export function removeFocus(element: FocusableElement) { + element.hasFocus = false; +} + +export function setFocus(element: FocusableElement) { + element.hasFocus = true; + element.focusElement?.scrollIntoView(); // Bring elements that are hidden by overflow into viewport +} + +export function visibleElement(element: FocusableElement): boolean { + const elementType = element?.tagName; + const grandparent = element?.parentElement?.parentElement; + + switch (elementType) { + case 'CDS-NAVIGATION-ITEM': + return element.hasAttribute('_expanded-group'); + + case 'CDS-NAVIGATION-START': + return !(grandparent?.tagName === 'CDS-NAVIGATION-GROUP' && !grandparent?.hasAttribute('expanded')); + + default: + return false; + } +} diff --git a/packages/core/web-dev-server.config.mjs b/packages/core/web-dev-server.config.mjs index 3cbb47c4a8..f6e03bb41c 100644 --- a/packages/core/web-dev-server.config.mjs +++ b/packages/core/web-dev-server.config.mjs @@ -127,6 +127,8 @@ export default /** @type {import('@web/dev-server').DevServerConfig} */ ({ '@cds/core/internal-components/panel/register.js': '/dist/core/internal-components/panel/register.js', '@cds/core/modal': '/dist/core/modal/index.js', '@cds/core/modal/register.js': '/dist/core/modal/register.js', + '@cds/core/navigation': '/dist/core/navigation/index.js', + '@cds/core/navigation/register.js': '/dist/core/navigation/register.js', '@cds/core/pagination': '/dist/core/pagination/index.js', '@cds/core/pagination/register.js': '/dist/core/pagination/register.js', '@cds/core/password': '/dist/core/password/index.js', diff --git a/packages/core/web-test-runner.config.mjs b/packages/core/web-test-runner.config.mjs index dc7928e57e..68980b0482 100644 --- a/packages/core/web-test-runner.config.mjs +++ b/packages/core/web-test-runner.config.mjs @@ -1,7 +1,7 @@ /** * Web Test Runner - * - * This configures Core unit tests to run using @web/test-runner + * + * This configures Core unit tests to run using @web/test-runner */ import { playwrightLauncher } from '@web/test-runner-playwright'; @@ -20,7 +20,14 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ coverageConfig: { require: ['ts-node/register'], extension: ['.ts'], - exclude: ['**/*.d.ts', '**/*.scss.js', '**/node_modules/**', '**/test/**', '**/dist/core/**/index.js', '**/dist/core/**/register.js'], + exclude: [ + '**/*.d.ts', + '**/*.scss.js', + '**/node_modules/**', + '**/test/**', + '**/dist/core/**/index.js', + '**/dist/core/**/register.js', + ], report: true, reportDir: 'dist/coverage', threshold: { @@ -34,7 +41,7 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ plugins: [ ...baseConfig.plugins, esbuildPlugin({ ts: true, json: true, target: 'auto' }), - fromRollup(execute)({ commands: [`tsc --noEmit src/**/*.spec.ts`], hook: 'writeBundle' }) + fromRollup(execute)({ commands: [`tsc --noEmit src/**/*.spec.ts`], hook: 'writeBundle' }), ], testRunnerHtml: (testRunnerImport, config) => ` @@ -45,5 +52,5 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ - ` + `, }); diff --git a/packages/react/App.tsx b/packages/react/App.tsx index 42079d02c8..16a65adf97 100644 --- a/packages/react/App.tsx +++ b/packages/react/App.tsx @@ -30,6 +30,7 @@ import { ClarityIcons, userIcon, timesIcon } from '@cds/core/icon'; import { CdsDivider } from './dist/react/divider/index.js'; import { CdsCard } from './dist/react/card/index.js'; import { CdsBreadcrumb } from './dist/react/breadcrumb/index.js'; +import { CdsNavigation, CdsNavigationGroup, CdsNavigationItem, CdsNavigationStart } from './dist/react/navigation'; ClarityIcons.addIcons(userIcon, timesIcon); @@ -42,6 +43,8 @@ interface AppState { panel2Expanded: boolean; panel3Expanded: boolean; panel4Expanded: boolean; + navigationOpen: boolean; + navigationGroupOpen: boolean; } export default class App extends React.Component<{}, AppState> { @@ -58,6 +61,8 @@ export default class App extends React.Component<{}, AppState> { panel2Expanded: false, panel3Expanded: false, panel4Expanded: false, + navigationOpen: true, + navigationGroupOpen: true, }; this.buttonRef = React.createRef(); } @@ -78,726 +83,751 @@ export default class App extends React.Component<{}, AppState> { const panel2Expanded = this.state.panel2Expanded; const panel3Expanded = this.state.panel3Expanded; const panel4Expanded = this.state.panel4Expanded; + const navigationOpen = this.state.navigationOpen; + const navigationGroupOpen = this.state.navigationGroupOpen; return ( -
-

Rendered by React!

- -

Accordion

- - { - const newVal = !panel1Expanded; - this.setState({ panel1Expanded: newVal }); - }} - > - Item 1 - Content 1 - - { - const newVal = !panel2Expanded; - this.setState({ panel2Expanded: newVal }); - }} - > - Item 2 - - - { - const newVal = !panel4Expanded; - this.setState({ panel4Expanded: newVal }); - }} - > - Item 2-1 - -

- Hundreds of thousands hydrogen atoms the sky calls to us not a sunrise but a galaxyrise culture - courage of our questions. Concept of the number one courage of our questions tingling of the spine - Flatland explorations are creatures of the cosmos. Finite but unbounded great turbulent clouds a - still more glorious dawn awaits corpus callosum vastness is bearable only through love - dispassionate extraterrestrial observer. The carbon in our apple pies extraordinary claims require - extraordinary evidence a very small stage in a vast cosmic arena gathered by gravity extraordinary - claims require extraordinary evidence permanence of the stars and billions upon billions upon - billions upon billions upon billions upon billions upon billions. -

-
-
-
-
-
- { - const newVal = !panel3Expanded; - this.setState({ panel3Expanded: newVal }); - }} - > - Item 3 – Should Not Open - Content 3 - -
- -

Breadcrumb

- - - Home - - - Parent page - - Current page - - -

Modal

-
- { - this.setState({ modalReady: true }); - const timer = setTimeout(() => { - this.setState({ modalOpen: true }); - clearTimeout(timer); - }, 25); - }} - > - Open Modal - -
- {isModalReady ? ( -
-