Skip to content

Commit

Permalink
feat: add side-nav router integration API (#7131)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomivirkki committed Feb 20, 2024
1 parent 2ab4f8e commit 4034234
Show file tree
Hide file tree
Showing 6 changed files with 286 additions and 1 deletion.
2 changes: 2 additions & 0 deletions packages/side-nav/src/vaadin-side-nav-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,14 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab
this.__updateCurrent();

window.addEventListener('popstate', this.__boundUpdateCurrent);
window.addEventListener('side-nav-location-changed', this.__boundUpdateCurrent);
}

/** @protected */
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('popstate', this.__boundUpdateCurrent);
window.removeEventListener('side-nav-location-changed', this.__boundUpdateCurrent);
}

/** @protected */
Expand Down
44 changes: 44 additions & 0 deletions packages/side-nav/src/vaadin-side-nav.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js';
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
import { SideNavChildrenMixin, type SideNavI18n } from './vaadin-side-nav-children-mixin.js';
import type { SideNavItem } from './vaadin-side-nav-item.js';

export type { SideNavI18n };

Expand All @@ -21,6 +22,15 @@ export interface SideNavCustomEventMap {

export type SideNavEventMap = HTMLElementEventMap & SideNavCustomEventMap;

export type NavigateEvent = {
path: SideNavItem['path'];
target: SideNavItem['target'];
current: SideNavItem['current'];
expanded: SideNavItem['expanded'];
pathAliases: SideNavItem['pathAliases'];
originalEvent: MouseEvent;
};

/**
* `<vaadin-side-nav>` is a Web Component for navigation menus.
*
Expand Down Expand Up @@ -83,6 +93,40 @@ declare class SideNav extends SideNavChildrenMixin(FocusMixin(ElementMixin(Thema
*/
collapsed: boolean;

/**
* Callback function for router integration.
*
* When a side nav item link is clicked, this function is called and the default click action is cancelled.
* This delegates the responsibility of navigation to the function's logic.
*
* The click event action is not cancelled in the following cases:
* - The click event has a modifier (e.g. `metaKey`, `shiftKey`)
* - The click event is on an external link
* - The click event is on a link with `target="_blank"`
* - The function explicitly returns `false`
*
* The function receives an object with the properties of the clicked side-nav item:
* - `path`: The path of the navigation item.
* - `target`: The target of the navigation item.
* - `current`: A boolean indicating whether the navigation item is currently selected.
* - `expanded`: A boolean indicating whether the navigation item is expanded.
* - `pathAliases`: An array of path aliases for the navigation item.
* - `originalEvent`: The original DOM event that triggered the navigation.
*
* Also see the `location` property for updating the highlighted navigation item on route change.
*/
onNavigate?: ((event: NavigateEvent) => boolean) | ((event: NavigateEvent) => void);

/**
* A change to this property triggers an update of the highlighted item in the side navigation. While it typically
* corresponds to the browser's URL, the specific value assigned to the property is irrelevant. The component has
* its own internal logic for determining which item is highlighted.
*
* The main use case for this property is when the side navigation is used with a client-side router. In this case,
* the component needs to be informed about route changes so it can update the highlighted item.
*/
location: any;

addEventListener<K extends keyof SideNavEventMap>(
type: K,
listener: (this: SideNav, ev: SideNavEventMap[K]) => void,
Expand Down
95 changes: 95 additions & 0 deletions packages/side-nav/src/vaadin-side-nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,48 @@ class SideNav extends SideNavChildrenMixin(FocusMixin(ElementMixin(ThemableMixin
notify: true,
reflectToAttribute: true,
},

/**
* Callback function for router integration.
*
* When a side nav item link is clicked, this function is called and the default click action is cancelled.
* This delegates the responsibility of navigation to the function's logic.
*
* The click event action is not cancelled in the following cases:
* - The click event has a modifier (e.g. `metaKey`, `shiftKey`)
* - The click event is on an external link
* - The click event is on a link with `target="_blank"`
* - The function explicitly returns `false`
*
* The function receives an object with the properties of the clicked side-nav item:
* - `path`: The path of the navigation item.
* - `target`: The target of the navigation item.
* - `current`: A boolean indicating whether the navigation item is currently selected.
* - `expanded`: A boolean indicating whether the navigation item is expanded.
* - `pathAliases`: An array of path aliases for the navigation item.
* - `originalEvent`: The original DOM event that triggered the navigation.
*
* Also see the `location` property for updating the highlighted navigation item on route change.
*
* @type {function(Object): boolean | undefined}
*/
onNavigate: {
attribute: false,
},

/**
* A change to this property triggers an update of the highlighted item in the side navigation. While it typically
* corresponds to the browser's URL, the specific value assigned to the property is irrelevant. The component has
* its own internal logic for determining which item is highlighted.
*
* The main use case for this property is when the side navigation is used with a client-side router. In this case,
* the component needs to be informed about route changes so it can update the highlighted item.
*
* @type {any}
*/
location: {
observer: '__locationChanged',
},
};
}

Expand All @@ -114,6 +156,7 @@ class SideNav extends SideNavChildrenMixin(FocusMixin(ElementMixin(ThemableMixin
super();

this._labelId = `side-nav-label-${generateUniqueId()}`;
this.addEventListener('click', this.__onClick);
}

/**
Expand Down Expand Up @@ -203,10 +246,62 @@ class SideNav extends SideNavChildrenMixin(FocusMixin(ElementMixin(ThemableMixin
}
}

/** @private */
__locationChanged() {
window.dispatchEvent(new CustomEvent('side-nav-location-changed'));
}

/** @private */
__toggleCollapsed() {
this.collapsed = !this.collapsed;
}

/** @private */
__onClick(e) {
if (!this.onNavigate) {
return;
}

const hasModifier = e.metaKey || e.shiftKey;
if (hasModifier) {
// Allow default action for clicks with modifiers
return;
}

const composedPath = e.composedPath();
const item = composedPath.find((el) => el.localName && el.localName.includes('side-nav-item'));
const anchor = composedPath.find((el) => el instanceof HTMLAnchorElement);
if (!item || !item.shadowRoot.contains(anchor)) {
// Not a click on a side-nav-item anchor
return;
}

const isRelative = anchor.href && anchor.href.startsWith(location.origin);
if (!isRelative) {
// Allow default action for external links
return;
}

if (item.target === '_blank') {
// Allow default action for links with target="_blank"
return;
}

// Call the onNavigate callback
const result = this.onNavigate({
path: item.path,
target: item.target,
current: item.current,
expanded: item.expanded,
pathAliases: item.pathAliases,
originalEvent: e,
});

if (result !== false) {
// Cancel the default action if the callback didn't return false
e.preventDefault();
}
}
}

defineCustomElement(SideNav);
Expand Down
119 changes: 119 additions & 0 deletions packages/side-nav/test/navigation-callback.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect } from '@esm-bundle/chai';
import { fixtureSync, nextRender } from '@vaadin/testing-helpers';
import sinon from 'sinon';
import '../vaadin-side-nav-item.js';
import '../vaadin-side-nav.js';

describe('navigation callback', () => {
let sideNav, sideNavItem;

function clickItemLink(item, props = {}) {
const itemLink = item.shadowRoot.querySelector('a');
const event = new MouseEvent('click', { bubbles: true, composed: true, cancelable: true, ...props });
itemLink.dispatchEvent(event);
return event;
}

beforeEach(async () => {
sideNav = fixtureSync(`
<vaadin-side-nav>
<a slot="label" href="/home">Home</a>
<vaadin-side-nav-item path="/foo">
foo
<vaadin-side-nav-item slot="children" path="/bar">bar</vaadin-side-nav-item>
</vaadin-side-nav-item>
</vaadin-side-nav>
`);

sideNav.addEventListener('click', (e) => {
const { defaultPrevented } = e;
// Prevent the tests from navigating away
e.preventDefault();
// Restore the defaultPrevented property
Object.defineProperty(e, 'defaultPrevented', { value: defaultPrevented });
});

sideNav.onNavigate = sinon.spy();

await nextRender();
sideNavItem = sideNav.querySelector('vaadin-side-nav-item');
});

it('should cancel the click event', () => {
const clickEvent = clickItemLink(sideNavItem);
expect(clickEvent.defaultPrevented).to.be.true;
});

it('should not cancel the click event on meta key + click', () => {
const clickEvent = clickItemLink(sideNavItem, { metaKey: true });
expect(clickEvent.defaultPrevented).to.be.false;
});

it('should not cancel the click event on shift key + click', () => {
const clickEvent = clickItemLink(sideNavItem, { shiftKey: true });
expect(clickEvent.defaultPrevented).to.be.false;
});

it('should not cancel the click event for external link', async () => {
sideNavItem.path = 'https://vaadin.com';
await nextRender();
const clickEvent = clickItemLink(sideNavItem);
expect(clickEvent.defaultPrevented).to.be.false;
});

it('should not cancel the click event if callback is not defined', () => {
sideNav.onNavigate = undefined;
const clickEvent = clickItemLink(sideNavItem);
expect(clickEvent.defaultPrevented).to.be.false;
});

it('should not cancel the click event if callback returns false', () => {
sideNav.onNavigate = () => false;
const clickEvent = clickItemLink(sideNavItem);
expect(clickEvent.defaultPrevented).to.be.false;
});

it('should not cancel the click event if target is _blank', () => {
sideNavItem.target = '_blank';
const clickEvent = clickItemLink(sideNavItem);
expect(clickEvent.defaultPrevented).to.be.false;
});

it('should not cancel label click event', () => {
const event = new MouseEvent('click', { bubbles: true, composed: true, cancelable: true });
const label = sideNav.querySelector('[slot="label"]');
expect(() => label.dispatchEvent(event)).to.not.throw();
expect(event.defaultPrevented).to.be.false;
});

it('should not cancel toggle click event', () => {
const event = new MouseEvent('click', { bubbles: true, composed: true, cancelable: true });
const toggle = sideNavItem.shadowRoot.querySelector('button');
expect(() => toggle.dispatchEvent(event)).to.not.throw();
expect(event.defaultPrevented).to.be.false;
});

it('should not cancel item click event', () => {
const event = new MouseEvent('click', { bubbles: true, composed: true, cancelable: true });
expect(() => sideNavItem.dispatchEvent(event)).to.not.throw();
expect(event.defaultPrevented).to.be.false;
});

it('should pass correct properties to the callback', () => {
const clickEvent = clickItemLink(sideNavItem);

expect(sideNav.onNavigate.calledOnce).to.be.true;
const callbackArguments = sideNav.onNavigate.firstCall.args;
expect(callbackArguments).to.eql([
{
path: '/foo',
target: undefined,
current: false,
expanded: false,
pathAliases: [],
originalEvent: clickEvent,
},
]);
});
});
10 changes: 10 additions & 0 deletions packages/side-nav/test/navigation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,14 @@ describe('navigation', () => {
await nextRender();
expect(items[1].hasAttribute('expanded')).to.be.true;
});

it('should update current attribute on location change', async () => {
expect(items[0].hasAttribute('current')).to.be.false;

history.pushState({}, '', '1');
sideNav.location = '1';

await nextRender();
expect(items[0].hasAttribute('current')).to.be.true;
});
});
17 changes: 16 additions & 1 deletion packages/side-nav/test/typings/side-nav.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import '../../vaadin-side-nav-item.js';
import type { DisabledMixinClass } from '@vaadin/a11y-base/src/disabled-mixin.js';
import type { ElementMixinClass } from '@vaadin/component-base/src/element-mixin.js';
import type { ThemableMixinClass } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
import type { SideNav, SideNavCollapsedChangedEvent, SideNavI18n } from '../../src/vaadin-side-nav';
import type { NavigateEvent, SideNav, SideNavCollapsedChangedEvent, SideNavI18n } from '../../src/vaadin-side-nav';
import type { SideNavChildrenMixinClass } from '../../src/vaadin-side-nav-children-mixin.js';
import type { SideNavItem, SideNavItemExpandedChangedEvent } from '../../src/vaadin-side-nav-item';

Expand All @@ -27,6 +27,21 @@ sideNav.addEventListener('collapsed-changed', (event) => {
assertType<boolean>(event.detail.value);
});

// Router integration
sideNav.onNavigate = undefined;
sideNav.onNavigate = () => false;
sideNav.onNavigate = (event) => {
assertType<NavigateEvent>(event);
assertType<string | null | undefined>(event.path);
assertType<string | null | undefined>(event.target);
assertType<boolean>(event.current);
assertType<boolean>(event.expanded);
assertType<string[]>(event.pathAliases);
assertType<MouseEvent>(event.originalEvent);
};

assertType<any>(sideNav.location);

const sideNavItem: SideNavItem = document.createElement('vaadin-side-nav-item');

// Item properties
Expand Down

0 comments on commit 4034234

Please sign in to comment.