mirror of
https://github.com/docsifyjs/docsify.git
synced 2025-12-08 19:55:52 +00:00
refactor: event modernization and optimization (#2404)
- Refactor methods names and functionality - Replace scroll listeners with observers - Replace Tweezer-based scrolling with native scroll methods - Remove tweezer.js dependency - Remove redundant method calls - Rename $resetEvents to onNavigate - Rename __scrollActiveSidebar to onRender - Remove __getAndActive - Remove __sticky - Add IntersectionObserver mock to Jest environment Also included: - Add e2e test “ui” and “chromium” scripts - Rename "jest" script to "test:jest" - Remove unused SSR code --------- Co-authored-by: Koy Zhuang <koy@ko8e24.top>
This commit is contained in:
parent
2d986feb34
commit
bb902f8997
8
package-lock.json
generated
8
package-lock.json
generated
@ -15,8 +15,7 @@
|
||||
"opencollective-postinstall": "^2.0.2",
|
||||
"prismjs": "^1.29.0",
|
||||
"strip-indent": "^3.0.0",
|
||||
"tinydate": "^1.3.0",
|
||||
"tweezer.js": "^1.4.0"
|
||||
"tinydate": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
@ -14739,11 +14738,6 @@
|
||||
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
|
||||
}
|
||||
},
|
||||
"node_modules/tweezer.js": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tweezer.js/-/tweezer.js-1.5.0.tgz",
|
||||
"integrity": "sha512-aSiJz7rGWNAQq7hjMK9ZYDuEawXupcCWgl3woQQSoDP2Oh8O4srWb/uO1PzzHIsrPEOqrjJ2sUb9FERfzuBabQ=="
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
19
package.json
19
package.json
@ -28,19 +28,18 @@
|
||||
"build:css": "mkdirp lib/themes && node build/css -o lib/themes",
|
||||
"build:emoji": "node ./build/emoji.js",
|
||||
"build:js": "cross-env NODE_ENV=production node build/build.js",
|
||||
"build:test": "npm run build && npm test",
|
||||
"build": "rimraf lib themes && run-s build:js build:css build:css:min build:cover build:emoji",
|
||||
"build": "run-s clean build:js build:css build:css:min build:cover build:emoji",
|
||||
"clean": "rimraf lib themes _playwright*",
|
||||
"dev": "run-p serve:dev watch:*",
|
||||
"docker:build:test": "npm run docker:cli -- build:test",
|
||||
"docker:build": "docker build -f Dockerfile -t docsify-test:local .",
|
||||
"docker:clean": "docker rmi docsify-test:local",
|
||||
"docker:cli": "docker run --rm -it --ipc=host --mount type=bind,source=$(pwd)/test,target=/app/test docsify-test:local",
|
||||
"docker:rebuild": "npm run docker:clean && npm run docker:build",
|
||||
"docker:rebuild": "run-s docker:clean docker:build",
|
||||
"docker:test:e2e": "npm run docker:cli -- test:e2e",
|
||||
"docker:test:integration": "npm run docker:cli -- test:integration",
|
||||
"docker:test:unit": "npm run docker:cli -- test:unit",
|
||||
"docker:test": "npm run docker:cli -- test",
|
||||
"jest": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "prettier . --check && eslint .",
|
||||
"postinstall": "opencollective-postinstall && npx husky install",
|
||||
@ -51,9 +50,12 @@
|
||||
"serve:dev": "npm run serve -- --dev",
|
||||
"serve": "node server",
|
||||
"test:e2e": "playwright test",
|
||||
"test:integration": "npm run jest -- --selectProjects integration",
|
||||
"test:unit": "npm run jest -- --selectProjects unit",
|
||||
"test": "npm run jest && run-s test:e2e",
|
||||
"test:e2e:chromium": "playwright test --project='chromium'",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:integration": "npm run test:jest -- --selectProjects integration",
|
||||
"test:jest": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
|
||||
"test:unit": "npm run test:jest -- --selectProjects unit",
|
||||
"test": "run-s test:jest test:e2e",
|
||||
"watch:css": "npm run build:css -- -w",
|
||||
"watch:js": "node build/build.js"
|
||||
},
|
||||
@ -66,8 +68,7 @@
|
||||
"opencollective-postinstall": "^2.0.2",
|
||||
"prismjs": "^1.29.0",
|
||||
"strip-indent": "^3.0.0",
|
||||
"tinydate": "^1.3.0",
|
||||
"tweezer.js": "^1.4.0"
|
||||
"tinydate": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import Tweezer from 'tweezer.js';
|
||||
import { isMobile } from '../util/env.js';
|
||||
import { body, on } from '../util/dom.js';
|
||||
import * as dom from '../util/dom.js';
|
||||
import { removeParams } from '../router/util.js';
|
||||
import config from '../config.js';
|
||||
|
||||
/** @typedef {import('../Docsify.js').Constructor} Constructor */
|
||||
|
||||
@ -13,50 +9,128 @@ import config from '../config.js';
|
||||
*/
|
||||
export function Events(Base) {
|
||||
return class Events extends Base {
|
||||
$resetEvents(source) {
|
||||
const { auto2top, loadNavbar } = this.config;
|
||||
const { path, query } = this.route;
|
||||
#intersectionObserver;
|
||||
#isScrolling;
|
||||
#title = dom.$.title;
|
||||
|
||||
// Note: Scroll position set by browser on forward/back (i.e. "history")
|
||||
if (source !== 'history') {
|
||||
// Scroll to ID if specified
|
||||
if (query.id) {
|
||||
this.#scrollIntoView(path, query.id, true);
|
||||
}
|
||||
// Scroll to top if a link was clicked and auto2top is enabled
|
||||
else if (source === 'navigate') {
|
||||
auto2top && this.#scroll2Top(auto2top);
|
||||
}
|
||||
// Initialization
|
||||
// =========================================================================
|
||||
/**
|
||||
* Initialize Docsify events
|
||||
* One-time setup of listeners, observers, and tasks.
|
||||
* @void
|
||||
*/
|
||||
initEvent() {
|
||||
const { topMargin } = this.config;
|
||||
|
||||
// Apply topMargin to scrolled content
|
||||
if (topMargin) {
|
||||
document.documentElement.style.setProperty(
|
||||
'scroll-padding-top',
|
||||
`${topMargin}px`
|
||||
);
|
||||
}
|
||||
|
||||
// Move focus to content
|
||||
if (query.id || source === 'navigate') {
|
||||
this.#focusContent();
|
||||
}
|
||||
|
||||
if (loadNavbar) {
|
||||
this.__getAndActive(this.router, 'nav');
|
||||
}
|
||||
this.#initCover();
|
||||
this.#initSkipToContent('#skip-to-content');
|
||||
this.#initSidebarCollapse('.sidebar');
|
||||
this.#initSidebarToggle('button.sidebar-toggle');
|
||||
this.#initKeyBindings();
|
||||
}
|
||||
|
||||
initEvent() {
|
||||
const { coverpage, keyBindings } = this.config;
|
||||
// Sub-Initializations
|
||||
// =========================================================================
|
||||
/**
|
||||
* Initialize cover observer
|
||||
* Toggles sticky behavior when when cover is not in view
|
||||
* @void
|
||||
*/
|
||||
#initCover() {
|
||||
const coverElm = dom.find('section.cover');
|
||||
|
||||
if (!coverElm) {
|
||||
dom.toggleClass(dom.body, 'add', 'sticky');
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
const isIntersecting = entries[0].isIntersecting;
|
||||
const op = isIntersecting ? 'remove' : 'add';
|
||||
|
||||
dom.toggleClass(dom.body, op, 'sticky');
|
||||
});
|
||||
|
||||
observer.observe(coverElm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize heading observer
|
||||
* Toggles sidebar active item based on top viewport edge intersection
|
||||
* @void
|
||||
*/
|
||||
#initHeadings() {
|
||||
const headingElms = dom.findAll('#main :where(h1, h2, h3, h4, h5)');
|
||||
const headingsInView = new Set();
|
||||
|
||||
// Mark sidebar active item on heading intersection
|
||||
this.#intersectionObserver?.disconnect();
|
||||
this.#intersectionObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
if (this.#isScrolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const op = entry.isIntersecting ? 'add' : 'delete';
|
||||
|
||||
headingsInView[op](entry.target);
|
||||
}
|
||||
|
||||
const activeHeading =
|
||||
headingsInView.size > 1
|
||||
? // Sort headings by proximity to viewport top and select first
|
||||
Array.from(headingsInView).sort((a, b) =>
|
||||
a.compareDocumentPosition(b) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
? -1
|
||||
: 1
|
||||
)[0]
|
||||
: // Get first and only item in set.
|
||||
// May be undefined if no headings are in view.
|
||||
headingsInView.values().next().value;
|
||||
|
||||
if (activeHeading) {
|
||||
const id = activeHeading.getAttribute('id');
|
||||
const href = this.router.toURL(this.router.getCurrentPath(), {
|
||||
id,
|
||||
});
|
||||
const newSidebarActiveElm = this.#markSidebarActiveElm(href);
|
||||
|
||||
newSidebarActiveElm?.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '0% 0% -50% 0%', // Top half of viewport
|
||||
}
|
||||
);
|
||||
|
||||
headingElms.forEach(elm => {
|
||||
this.#intersectionObserver.observe(elm);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize keyboard bindings
|
||||
* @void
|
||||
*/
|
||||
#initKeyBindings() {
|
||||
const { keyBindings } = this.config;
|
||||
const modifierKeys = ['alt', 'ctrl', 'meta', 'shift'];
|
||||
|
||||
// Bind skip link
|
||||
this.#skipLink('#skip-to-content');
|
||||
|
||||
// Bind toggle button
|
||||
this.#btn('button.sidebar-toggle', this.router);
|
||||
this.#collapse('.sidebar', this.router);
|
||||
|
||||
// Bind sticky effect
|
||||
if (coverpage) {
|
||||
!isMobile && on('scroll', this.__sticky);
|
||||
} else {
|
||||
body.classList.add('sticky');
|
||||
}
|
||||
// Bind keyboard shortcuts
|
||||
if (keyBindings && keyBindings.constructor === Object) {
|
||||
// Prepare key binding configurations
|
||||
Object.values(keyBindings || []).forEach(bindingConfig => {
|
||||
@ -96,7 +170,7 @@ export function Events(Base) {
|
||||
});
|
||||
|
||||
// Handle keyboard events
|
||||
on('keydown', e => {
|
||||
dom.on('keydown', e => {
|
||||
const isTextEntry = document.activeElement.matches(
|
||||
'input, select, textarea'
|
||||
);
|
||||
@ -131,243 +205,20 @@ export function Events(Base) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @readonly */
|
||||
#nav = {};
|
||||
|
||||
#hoverOver = false;
|
||||
#scroller = null;
|
||||
#enableScrollEvent = true;
|
||||
#coverHeight = 0;
|
||||
|
||||
#skipLink(elm) {
|
||||
elm = typeof elm === 'string' ? dom.find(elm) : elm;
|
||||
|
||||
elm?.addEventListener('click', evt => {
|
||||
evt.preventDefault();
|
||||
dom.getNode('main')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
this.#focusContent({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
#scrollTo(el, offset = 0) {
|
||||
if (this.#scroller) {
|
||||
this.#scroller.stop();
|
||||
}
|
||||
|
||||
this.#enableScrollEvent = false;
|
||||
this.#scroller = new Tweezer({
|
||||
start: window.pageYOffset,
|
||||
end:
|
||||
Math.round(el.getBoundingClientRect().top) +
|
||||
window.pageYOffset -
|
||||
offset,
|
||||
duration: 500,
|
||||
})
|
||||
.on('tick', v => window.scrollTo(0, v))
|
||||
.on('done', () => {
|
||||
this.#enableScrollEvent = true;
|
||||
this.#scroller = null;
|
||||
})
|
||||
.begin();
|
||||
}
|
||||
|
||||
#focusContent(options = {}) {
|
||||
const { query } = this.route;
|
||||
const focusEl = query.id
|
||||
? // Heading ID
|
||||
dom.find(`#${query.id}`)
|
||||
: // First heading
|
||||
dom.find('#main :where(h1, h2, h3, h4, h5, h6)') ||
|
||||
// Content container
|
||||
dom.find('#main');
|
||||
|
||||
// Move focus to content area
|
||||
focusEl && focusEl.focus(options);
|
||||
}
|
||||
|
||||
#highlight(path) {
|
||||
if (!this.#enableScrollEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sidebar = dom.getNode('.sidebar');
|
||||
const anchors = dom.findAll('.anchor');
|
||||
const wrap = dom.find(sidebar, '.sidebar-nav');
|
||||
let active = dom.find(sidebar, 'li.active');
|
||||
const doc = document.documentElement;
|
||||
const top =
|
||||
((doc && doc.scrollTop) || document.body.scrollTop) - this.#coverHeight;
|
||||
let last;
|
||||
|
||||
for (const node of anchors) {
|
||||
if (node.offsetTop > top) {
|
||||
if (!last) {
|
||||
last = node;
|
||||
}
|
||||
|
||||
break;
|
||||
} else {
|
||||
last = node;
|
||||
}
|
||||
}
|
||||
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
|
||||
const li = this.#nav[this.#getNavKey(path, last.getAttribute('data-id'))];
|
||||
|
||||
if (!li || li === active) {
|
||||
return;
|
||||
}
|
||||
|
||||
active && active.classList.remove('active');
|
||||
li.classList.add('active');
|
||||
active = li;
|
||||
|
||||
// Scroll into view
|
||||
// https://github.com/vuejs/vuejs.org/blob/master/themes/vue/source/js/common.js#L282-L297
|
||||
if (!this.#hoverOver && dom.body.classList.contains('sticky')) {
|
||||
const height = sidebar.clientHeight;
|
||||
const curOffset = 0;
|
||||
const cur = active.offsetTop + active.clientHeight + 40;
|
||||
const isInView =
|
||||
active.offsetTop >= wrap.scrollTop && cur <= wrap.scrollTop + height;
|
||||
const notThan = cur - curOffset < height;
|
||||
|
||||
sidebar.scrollTop = isInView
|
||||
? wrap.scrollTop
|
||||
: notThan
|
||||
? curOffset
|
||||
: cur - height;
|
||||
}
|
||||
}
|
||||
|
||||
#getNavKey(path, id) {
|
||||
return `${decodeURIComponent(path)}?id=${decodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
__scrollActiveSidebar(router) {
|
||||
const cover = dom.find('.cover.show');
|
||||
this.#coverHeight = cover ? cover.offsetHeight : 0;
|
||||
|
||||
const sidebar = dom.getNode('.sidebar');
|
||||
let lis = [];
|
||||
if (sidebar !== null && sidebar !== undefined) {
|
||||
lis = dom.findAll(sidebar, 'li');
|
||||
}
|
||||
|
||||
for (const li of lis) {
|
||||
const a = li.querySelector('a');
|
||||
if (!a) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let href = a.getAttribute('href');
|
||||
|
||||
if (href !== '/') {
|
||||
const {
|
||||
query: { id },
|
||||
path,
|
||||
} = router.parse(href);
|
||||
if (id) {
|
||||
href = this.#getNavKey(path, id);
|
||||
}
|
||||
}
|
||||
|
||||
if (href) {
|
||||
this.#nav[decodeURIComponent(href)] = li;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = removeParams(router.getCurrentPath());
|
||||
dom.off('scroll', () => this.#highlight(path));
|
||||
dom.on('scroll', () => this.#highlight(path));
|
||||
dom.on(sidebar, 'mouseover', () => {
|
||||
this.#hoverOver = true;
|
||||
});
|
||||
dom.on(sidebar, 'mouseleave', () => {
|
||||
this.#hoverOver = false;
|
||||
});
|
||||
}
|
||||
|
||||
#scrollIntoView(path, id) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const topMargin = config().topMargin;
|
||||
// Use [id='1234'] instead of #id to handle special cases such as reserved characters and pure number id
|
||||
// https://stackoverflow.com/questions/37270787/uncaught-syntaxerror-failed-to-execute-queryselector-on-document
|
||||
const section = dom.find(`[id="${id}"]`);
|
||||
section && this.#scrollTo(section, topMargin);
|
||||
|
||||
const sidebar = dom.getNode('.sidebar');
|
||||
const oldActive = dom.find(sidebar, 'li.active');
|
||||
const oldPage = dom.find(sidebar, `[aria-current]`);
|
||||
const newActive = this.#nav[this.#getNavKey(path, id)];
|
||||
const newPage = dom.find(sidebar, `[href$="${path}"]`)?.parentNode;
|
||||
oldActive?.classList.remove('active');
|
||||
oldPage?.removeAttribute('aria-current');
|
||||
newActive?.classList.add('active');
|
||||
newPage?.setAttribute('aria-current', 'page');
|
||||
}
|
||||
|
||||
#scrollEl = dom.$.scrollingElement || dom.$.documentElement;
|
||||
|
||||
#scroll2Top(offset = 0) {
|
||||
this.#scrollEl.scrollTop = offset === true ? 0 : Number(offset);
|
||||
}
|
||||
|
||||
/** @readonly */
|
||||
#title = dom.$.title;
|
||||
|
||||
/**
|
||||
* Toggle button
|
||||
* @param {Element} el Button to be toggled
|
||||
* Initialize sidebar content expand/collapse toggle behavior
|
||||
*
|
||||
* @param {Element|string} elm Sidebar Element or CSS selector
|
||||
* @void
|
||||
*/
|
||||
#btn(el) {
|
||||
const toggle = _ => {
|
||||
dom.body.classList.toggle('close');
|
||||
#initSidebarCollapse(elm) {
|
||||
elm = typeof elm === 'string' ? document.querySelector(elm) : elm;
|
||||
|
||||
const isClosed = isMobile
|
||||
? dom.body.classList.contains('close')
|
||||
: !dom.body.classList.contains('close');
|
||||
|
||||
el.setAttribute('aria-expanded', isClosed);
|
||||
};
|
||||
|
||||
el = dom.getNode(el);
|
||||
if (el === null || el === undefined) {
|
||||
if (!elm) {
|
||||
return;
|
||||
}
|
||||
|
||||
dom.on(el, 'click', e => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
});
|
||||
|
||||
isMobile &&
|
||||
dom.on(
|
||||
dom.body,
|
||||
'click',
|
||||
_ => dom.body.classList.contains('close') && toggle()
|
||||
);
|
||||
}
|
||||
|
||||
#collapse(el) {
|
||||
el = dom.getNode(el);
|
||||
if (el === null || el === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
dom.on(el, 'click', ({ target }) => {
|
||||
dom.on(elm, 'click', ({ target }) => {
|
||||
if (
|
||||
target.nodeName === 'A' &&
|
||||
target.nextSibling &&
|
||||
@ -379,67 +230,293 @@ export function Events(Base) {
|
||||
});
|
||||
}
|
||||
|
||||
__sticky = () => {
|
||||
const cover = dom.getNode('section.cover');
|
||||
if (!cover) {
|
||||
/**
|
||||
* Initialize sidebar show/hide toggle behavior
|
||||
*
|
||||
* @param {Element|string} elm Toggle Element or CSS selector
|
||||
* @void
|
||||
*/
|
||||
#initSidebarToggle(elm) {
|
||||
elm = typeof elm === 'string' ? document.querySelector(elm) : elm;
|
||||
|
||||
if (!elm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const coverHeight = cover.getBoundingClientRect().height;
|
||||
const toggle = () => {
|
||||
dom.body.classList.toggle('close');
|
||||
|
||||
if (
|
||||
window.pageYOffset >= coverHeight ||
|
||||
cover.classList.contains('hidden')
|
||||
) {
|
||||
dom.toggleClass(dom.body, 'add', 'sticky');
|
||||
} else {
|
||||
dom.toggleClass(dom.body, 'remove', 'sticky');
|
||||
}
|
||||
};
|
||||
const isClosed = isMobile
|
||||
? dom.body.classList.contains('close')
|
||||
: !dom.body.classList.contains('close');
|
||||
|
||||
elm.setAttribute('aria-expanded', isClosed);
|
||||
};
|
||||
|
||||
dom.on(elm, 'click', e => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
});
|
||||
|
||||
isMobile &&
|
||||
dom.on(
|
||||
dom.body,
|
||||
'click',
|
||||
() => dom.body.classList.contains('close') && toggle()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and active link
|
||||
* @param {Object} router Router
|
||||
* @param {String|Element} el Target element
|
||||
* @param {Boolean} isParent Active parent
|
||||
* @param {Boolean} autoTitle Automatically set title
|
||||
* @return {Element} Active element
|
||||
* Initialize skip to content behavior
|
||||
*
|
||||
* @param {Element|string} elm Skip link Element or CSS selector
|
||||
* @void
|
||||
*/
|
||||
__getAndActive(router, el, isParent, autoTitle) {
|
||||
el = dom.getNode(el);
|
||||
let links = [];
|
||||
if (el !== null && el !== undefined) {
|
||||
links = dom.findAll(el, 'a');
|
||||
#initSkipToContent(elm) {
|
||||
elm = typeof elm === 'string' ? document.querySelector(elm) : elm;
|
||||
|
||||
if (!elm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = decodeURI(router.toURL(router.getCurrentPath()));
|
||||
let target;
|
||||
|
||||
links
|
||||
.sort((a, b) => b.href.length - a.href.length)
|
||||
.forEach(a => {
|
||||
const href = decodeURI(a.getAttribute('href'));
|
||||
const node = isParent ? a.parentNode : a;
|
||||
|
||||
a.title = a.title || a.innerText;
|
||||
|
||||
if (hash.indexOf(href) === 0 && !target) {
|
||||
target = a;
|
||||
dom.toggleClass(node, 'add', 'active');
|
||||
node.setAttribute('aria-current', 'page');
|
||||
} else {
|
||||
dom.toggleClass(node, 'remove', 'active');
|
||||
node.removeAttribute('aria-current');
|
||||
}
|
||||
elm.addEventListener('click', evt => {
|
||||
evt.preventDefault();
|
||||
dom.find('main')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
this.#focusContent();
|
||||
});
|
||||
}
|
||||
|
||||
if (autoTitle) {
|
||||
dom.$.title = target
|
||||
? target.title || `${target.innerText} - ${this.#title}`
|
||||
: this.#title;
|
||||
// Callbacks
|
||||
// =========================================================================
|
||||
/**
|
||||
* Handle rendering UI element updates and new content
|
||||
* @void
|
||||
*/
|
||||
onRender() {
|
||||
const currentPath = this.router.toURL(this.router.getCurrentPath());
|
||||
const currentTitle = dom.find(
|
||||
`.sidebar a[href='${currentPath}']`
|
||||
)?.innerText;
|
||||
|
||||
// Update page title
|
||||
dom.$.title = currentTitle || this.#title;
|
||||
|
||||
this.#markAppNavActiveElm();
|
||||
this.#markSidebarCurrentPage();
|
||||
this.#initHeadings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation events
|
||||
*
|
||||
* @param {undefined|"history"|"navigate"} source Type of navigation where
|
||||
* undefined is initial load, "history" is forward/back, and "navigate" is
|
||||
* user click/tap
|
||||
* @void
|
||||
*/
|
||||
onNavigate(source) {
|
||||
const { auto2top, topMargin } = this.config;
|
||||
const { query } = this.route;
|
||||
|
||||
this.#markSidebarActiveElm();
|
||||
|
||||
// Note: Scroll position set by browser on forward/back (i.e. "history")
|
||||
if (source !== 'history') {
|
||||
// Anchor link
|
||||
if (query.id) {
|
||||
const headingElm = dom.find(
|
||||
`.markdown-section :where(h1, h2, h3, h4, h5)[id="${query.id}"]`
|
||||
);
|
||||
|
||||
if (headingElm) {
|
||||
this.#watchNextScroll();
|
||||
headingElm.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}
|
||||
// User click/tap
|
||||
else if (source === 'navigate') {
|
||||
// Scroll to top
|
||||
if (auto2top) {
|
||||
document.scrollingElement.scrollTop = topMargin ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
// Move focus to content
|
||||
if (query.id || source === 'navigate') {
|
||||
this.#focusContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Functions
|
||||
// =========================================================================
|
||||
/**
|
||||
* Set focus on the main content area: current route ID, first heading, or
|
||||
* the main content container
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus
|
||||
* @param {Object} options HTMLElement focus() method options
|
||||
* @void
|
||||
*/
|
||||
#focusContent(options = {}) {
|
||||
const settings = {
|
||||
preventScroll: true,
|
||||
...options,
|
||||
};
|
||||
const { query } = this.route;
|
||||
const focusEl = query.id
|
||||
? // Heading ID
|
||||
dom.find(`#${query.id}`)
|
||||
: // First heading
|
||||
dom.find('#main :where(h1, h2, h3, h4, h5, h6)') ||
|
||||
// Content container
|
||||
dom.find('#main');
|
||||
|
||||
// Move focus to content area
|
||||
focusEl?.focus(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the active app nav item
|
||||
*
|
||||
* @param {string} [href] Matching element HREF value. If unspecified,
|
||||
* defaults to the current path (without query params)
|
||||
* @returns Element|undefined
|
||||
*/
|
||||
#markAppNavActiveElm() {
|
||||
const href = decodeURIComponent(this.router.toURL(this.route.path));
|
||||
const navElm = dom.find('nav.app-nav');
|
||||
|
||||
if (!navElm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newActive = dom
|
||||
.findAll(navElm, 'a')
|
||||
.sort((a, b) => b.href.length - a.href.length)
|
||||
.find(
|
||||
a =>
|
||||
href.includes(a.getAttribute('href')) ||
|
||||
href.includes(decodeURI(a.getAttribute('href')))
|
||||
);
|
||||
const oldActive = dom.find(navElm, 'li.active');
|
||||
|
||||
if (newActive && newActive !== oldActive) {
|
||||
oldActive?.classList.remove('active');
|
||||
newActive.classList.add('active');
|
||||
}
|
||||
|
||||
return newActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the active sidebar item
|
||||
*
|
||||
* @param {string} [href] Matching element HREF value. If unspecified,
|
||||
* defaults to the current path (with query params)
|
||||
* @returns Element|undefined
|
||||
*/
|
||||
#markSidebarActiveElm(href) {
|
||||
href ??= this.router.toURL(this.router.getCurrentPath());
|
||||
|
||||
const sidebar = dom.find('.sidebar');
|
||||
|
||||
if (!sidebar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldActive = dom.find(sidebar, 'li.active');
|
||||
const newActive = dom
|
||||
.find(
|
||||
sidebar,
|
||||
`a[href="${href}"], a[href="${decodeURIComponent(href)}"]`
|
||||
)
|
||||
?.closest('li');
|
||||
|
||||
if (newActive && newActive !== oldActive) {
|
||||
oldActive?.classList.remove('active');
|
||||
newActive.classList.add('active');
|
||||
}
|
||||
|
||||
return newActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the current page in the sidebar
|
||||
*
|
||||
* @param {string} [href] Matching sidebar element HREF value. If
|
||||
* unspecified, defaults to the current path (without query params)
|
||||
* @returns Element|undefined
|
||||
*/
|
||||
#markSidebarCurrentPage(href) {
|
||||
href ??= this.router.toURL(this.route.path);
|
||||
|
||||
const sidebar = dom.find('.sidebar');
|
||||
|
||||
if (!sidebar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = href?.split('?')[0];
|
||||
const oldPage = dom.find(sidebar, 'li[aria-current]');
|
||||
const newPage = dom
|
||||
.find(
|
||||
sidebar,
|
||||
`a[href="${path}"], a[href="${decodeURIComponent(path)}"]`
|
||||
)
|
||||
?.closest('li');
|
||||
|
||||
if (newPage && newPage !== oldPage) {
|
||||
oldPage?.removeAttribute('aria-current');
|
||||
newPage.setAttribute('aria-current', 'page');
|
||||
}
|
||||
|
||||
return newPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor next scroll start/end and set #isScrolling to true/false
|
||||
* accordingly. Listeners are removed after the start/end events are fired.
|
||||
* @void
|
||||
*/
|
||||
#watchNextScroll() {
|
||||
// Scroll start
|
||||
document.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
this.#isScrolling = true;
|
||||
|
||||
// Scroll end
|
||||
if ('onscrollend' in window) {
|
||||
document.addEventListener(
|
||||
'scrollend',
|
||||
() => (this.#isScrolling = false),
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
// Browsers w/o native scrollend event support (Safari)
|
||||
else {
|
||||
const callback = () => {
|
||||
clearTimeout(scrollTimer);
|
||||
|
||||
scrollTimer = setTimeout(() => {
|
||||
document.removeEventListener('scroll', callback);
|
||||
this.#isScrolling = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
let scrollTimer;
|
||||
|
||||
document.addEventListener('scroll', callback, false);
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -184,7 +184,7 @@ export function Fetch(Base) {
|
||||
}
|
||||
}
|
||||
|
||||
$fetch(cb = noop, $resetEvents = this.$resetEvents.bind(this)) {
|
||||
$fetch(cb = noop, onNavigate = this.onNavigate.bind(this)) {
|
||||
const done = () => {
|
||||
this.callHook('doneEach');
|
||||
cb();
|
||||
@ -196,7 +196,7 @@ export function Fetch(Base) {
|
||||
done();
|
||||
} else {
|
||||
this._fetch(() => {
|
||||
$resetEvents();
|
||||
onNavigate();
|
||||
done();
|
||||
});
|
||||
}
|
||||
@ -262,25 +262,7 @@ export function Fetch(Base) {
|
||||
initFetch() {
|
||||
const { loadSidebar } = this.config;
|
||||
|
||||
// Server-Side Rendering
|
||||
if (this.rendered) {
|
||||
const activeEl = this.__getAndActive(
|
||||
this.router,
|
||||
'.sidebar-nav',
|
||||
true,
|
||||
true
|
||||
);
|
||||
if (loadSidebar && activeEl) {
|
||||
activeEl.parentNode.innerHTML += window.__SUB_SIDEBAR__;
|
||||
}
|
||||
|
||||
this._bindEventOnRendered(activeEl);
|
||||
this.$resetEvents();
|
||||
this.callHook('doneEach');
|
||||
this.callHook('ready');
|
||||
} else {
|
||||
this.$fetch(_ => this.callHook('ready'));
|
||||
}
|
||||
this.$fetch(_ => this.callHook('ready'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -18,6 +18,14 @@ export function Render(Base) {
|
||||
return class Render extends Base {
|
||||
#vueGlobalData;
|
||||
|
||||
#addTextAsTitleAttribute(cssSelector) {
|
||||
dom.findAll(cssSelector).forEach(elm => {
|
||||
if (!elm.title && elm.innerText) {
|
||||
elm.title = elm.innerText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#executeScript() {
|
||||
const script = dom
|
||||
.findAll('.markdown-section>script')
|
||||
@ -293,12 +301,10 @@ export function Render(Base) {
|
||||
this._renderTo('.sidebar-nav', this.compiler.sidebar(text, maxLevel));
|
||||
sidebarToggleEl.setAttribute('aria-expanded', !isMobile);
|
||||
|
||||
const activeEl = this.__getAndActive(
|
||||
this.router,
|
||||
'.sidebar-nav',
|
||||
true,
|
||||
true
|
||||
);
|
||||
const activeElmHref = this.router.toURL(this.route.path);
|
||||
const activeEl = dom.find(`.sidebar-nav a[href="${activeElmHref}"]`);
|
||||
|
||||
this.#addTextAsTitleAttribute('.sidebar-nav a');
|
||||
|
||||
if (loadSidebar && activeEl) {
|
||||
activeEl.parentNode.innerHTML +=
|
||||
@ -315,7 +321,7 @@ export function Render(Base) {
|
||||
_bindEventOnRendered(activeEl) {
|
||||
const { autoHeader } = this.config;
|
||||
|
||||
this.__scrollActiveSidebar(this.router);
|
||||
this.onRender();
|
||||
|
||||
if (autoHeader && activeEl) {
|
||||
const main = dom.getNode('#main');
|
||||
@ -330,9 +336,7 @@ export function Render(Base) {
|
||||
|
||||
_renderNav(text) {
|
||||
text && this._renderTo('nav', this.compiler.compile(text));
|
||||
if (this.config.loadNavbar) {
|
||||
this.__getAndActive(this.router, 'nav');
|
||||
}
|
||||
this.#addTextAsTitleAttribute('nav a');
|
||||
}
|
||||
|
||||
_renderMain(text, opt = {}, next) {
|
||||
@ -420,7 +424,6 @@ export function Render(Base) {
|
||||
}
|
||||
|
||||
this._renderTo('.cover-main', html);
|
||||
this.__sticky();
|
||||
}
|
||||
|
||||
_updateRender() {
|
||||
|
||||
@ -49,11 +49,11 @@ export function Router(Base) {
|
||||
this._updateRender();
|
||||
|
||||
if (lastRoute.path === this.route.path) {
|
||||
this.$resetEvents(params.source);
|
||||
this.onNavigate(params.source);
|
||||
return;
|
||||
}
|
||||
|
||||
this.$fetch(noop, this.$resetEvents.bind(this, params.source));
|
||||
this.$fetch(noop, this.onNavigate.bind(this, params.source));
|
||||
lastRoute = this.route;
|
||||
});
|
||||
}
|
||||
|
||||
@ -273,6 +273,9 @@ main.hidden
|
||||
line-height 2em
|
||||
padding-bottom 40px
|
||||
|
||||
li
|
||||
scroll-margin-bottom 40px
|
||||
|
||||
li.collapse
|
||||
.app-sub-sidebar
|
||||
display none
|
||||
|
||||
@ -21,8 +21,32 @@ const sideEffects = {
|
||||
},
|
||||
};
|
||||
|
||||
class IntersectionObserver {
|
||||
constructor() {}
|
||||
|
||||
root = null;
|
||||
rootMargin = '';
|
||||
thresholds = [];
|
||||
|
||||
disconnect() {
|
||||
return null;
|
||||
}
|
||||
|
||||
observe() {
|
||||
return null;
|
||||
}
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
|
||||
unobserve() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle Hooks
|
||||
// -----------------------------------------------------------------------------
|
||||
// =============================================================================
|
||||
beforeAll(async () => {
|
||||
// Spy addEventListener
|
||||
['document', 'window'].forEach(obj => {
|
||||
@ -44,13 +68,16 @@ beforeAll(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Reset JSDOM. This attempts to remove side effects from tests, however it does
|
||||
// not reset all changes made to globals like the window and document
|
||||
// objects. Tests requiring a full JSDOM reset should be stored in separate
|
||||
// files, which is only way to do a complete JSDOM reset with Jest.
|
||||
beforeEach(async () => {
|
||||
const rootElm = document.documentElement;
|
||||
|
||||
// Reset JSDOM
|
||||
// -----------------------------------------------------------------------------
|
||||
// This attempts to remove side effects from tests, however it does not reset
|
||||
// all changes made to globals like the window and document objects. Tests
|
||||
// requiring a full JSDOM reset should be stored in separate files, which is
|
||||
// only way to do a complete JSDOM reset with Jest.
|
||||
|
||||
// Remove attributes on root element
|
||||
[...rootElm.attributes].forEach(attr => rootElm.removeAttribute(attr.name));
|
||||
|
||||
@ -79,6 +106,12 @@ beforeEach(async () => {
|
||||
|
||||
// Restore base elements
|
||||
rootElm.innerHTML = '<head></head><body></body>';
|
||||
|
||||
// Mock IntersectionObserver
|
||||
// -----------------------------------------------------------------------------
|
||||
[global, window].forEach(
|
||||
obj => (obj.IntersectionObserver = IntersectionObserver)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@ -44,6 +44,10 @@ test.describe('Sidebar Tests', () => {
|
||||
|
||||
await docsifyInit(docsifyInitConfig);
|
||||
|
||||
await page.click('a[href="#/test"]');
|
||||
await expect(activeLinkElm).toHaveText('Test');
|
||||
expect(page.url()).toMatch(/\/test$/);
|
||||
|
||||
await page.click('a[href="#/test%20space"]');
|
||||
await expect(activeLinkElm).toHaveText('Test Space');
|
||||
expect(page.url()).toMatch(/\/test%20space$/);
|
||||
@ -57,15 +61,11 @@ test.describe('Sidebar Tests', () => {
|
||||
expect(page.url()).toMatch(/\/test-foo$/);
|
||||
|
||||
await page.click('a[href="#/test.foo"]');
|
||||
expect(page.url()).toMatch(/\/test.foo$/);
|
||||
await expect(activeLinkElm).toHaveText('Test .');
|
||||
expect(page.url()).toMatch(/\/test.foo$/);
|
||||
|
||||
await page.click('a[href="#/test>foo"]');
|
||||
await expect(activeLinkElm).toHaveText('Test >');
|
||||
expect(page.url()).toMatch(/\/test%3Efoo$/);
|
||||
|
||||
await page.click('a[href="#/test"]');
|
||||
await expect(activeLinkElm).toHaveText('Test');
|
||||
expect(page.url()).toMatch(/\/test$/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,11 +16,11 @@ exports[`Docs Site coverpage renders and is unchanged 1`] = `
|
||||
</section>"
|
||||
`;
|
||||
|
||||
exports[`Docs Site navbar renders and is unchanged 1`] = `"<nav aria-label=\\"secondary\\" class=\\"app-nav no-badge\\"><ul><li>Translations<ul><li><a href=\\"#/\\" title=\\"undefined\\" class=\\"active\\" aria-current=\\"page\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1ec-1f1e7.png?v8.png\\" alt=\\"uk\\" class=\\"emoji\\" loading=\\"lazy\\"> English</a></li><li><a href=\\"#/zh-cn/\\" title=\\"undefined\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1e8-1f1f3.png?v8.png\\" alt=\\"cn\\" class=\\"emoji\\" loading=\\"lazy\\"> 简体中文</a></li><li><a href=\\"#/de-de/\\" title=\\"undefined\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1e9-1f1ea.png?v8.png\\" alt=\\"de\\" class=\\"emoji\\" loading=\\"lazy\\"> Deutsch</a></li><li><a href=\\"#/es/\\" title=\\"undefined\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1ea-1f1f8.png?v8.png\\" alt=\\"es\\" class=\\"emoji\\" loading=\\"lazy\\"> Español</a></li><li><a href=\\"#/ru-ru/\\" title=\\"undefined\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1f7-1f1fa.png?v8.png\\" alt=\\"ru\\" class=\\"emoji\\" loading=\\"lazy\\"> Русский</a></li></ul></li></ul></nav>"`;
|
||||
exports[`Docs Site navbar renders and is unchanged 1`] = `"<nav aria-label=\\"secondary\\" class=\\"app-nav no-badge\\"><ul><li>Translations<ul><li><a href=\\"#/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1ec-1f1e7.png?v8.png\\" alt=\\"uk\\" class=\\"emoji\\" loading=\\"lazy\\"> English</a></li><li><a href=\\"#/zh-cn/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1e8-1f1f3.png?v8.png\\" alt=\\"cn\\" class=\\"emoji\\" loading=\\"lazy\\"> 简体中文</a></li><li><a href=\\"#/de-de/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1e9-1f1ea.png?v8.png\\" alt=\\"de\\" class=\\"emoji\\" loading=\\"lazy\\"> Deutsch</a></li><li><a href=\\"#/es/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1ea-1f1f8.png?v8.png\\" alt=\\"es\\" class=\\"emoji\\" loading=\\"lazy\\"> Español</a></li><li><a href=\\"#/ru-ru/\\"><img src=\\"https://github.githubassets.com/images/icons/emoji/unicode/1f1f7-1f1fa.png?v8.png\\" alt=\\"ru\\" class=\\"emoji\\" loading=\\"lazy\\"> Русский</a></li></ul></li></ul></nav>"`;
|
||||
|
||||
exports[`Docs Site sidebar renders and is unchanged 1`] = `
|
||||
"<aside id=\\"__sidebar\\" class=\\"sidebar\\" role=\\"none\\">
|
||||
|
||||
<div class=\\"sidebar-nav\\" role=\\"navigation\\" aria-label=\\"primary\\"><ul><li><p>Getting started</p><ul><li><a href=\\"#/quickstart\\" title=\\"undefined\\">Quick start</a></li><li><a href=\\"#/more-pages\\" title=\\"undefined\\">Writing more pages</a></li><li><a href=\\"#/custom-navbar\\" title=\\"undefined\\">Custom navbar</a></li><li><a href=\\"#/cover\\" title=\\"undefined\\">Cover page</a></li></ul></li><li><p>Customization</p><ul><li><a href=\\"#/configuration\\" title=\\"undefined\\">Configuration</a></li><li><a href=\\"#/themes\\" title=\\"undefined\\">Themes</a></li><li><a href=\\"#/plugins\\" title=\\"undefined\\">List of Plugins</a></li><li><a href=\\"#/write-a-plugin\\" title=\\"undefined\\">Write a Plugin</a></li><li><a href=\\"#/markdown\\" title=\\"undefined\\">Markdown configuration</a></li><li><a href=\\"#/language-highlight\\" title=\\"undefined\\">Language highlighting</a></li><li><a href=\\"#/emoji\\" title=\\"undefined\\">Emoji</a></li></ul></li><li><p>Guide</p><ul><li><a href=\\"#/deploy\\" title=\\"undefined\\">Deploy</a></li><li><a href=\\"#/helpers\\" title=\\"undefined\\">Helpers</a></li><li><a href=\\"#/vue\\" title=\\"undefined\\">Vue compatibility</a></li><li><a href=\\"#/cdn\\" title=\\"undefined\\">CDN</a></li><li><a href=\\"#/pwa\\" title=\\"undefined\\">Offline Mode (PWA)</a></li><li><a href=\\"#/embed-files\\" title=\\"undefined\\">Embed Files</a></li></ul></li><li><p><a href=\\"#/awesome\\" title=\\"undefined\\">Awesome docsify</a></p></li><li><p><a href=\\"#/changelog\\" title=\\"undefined\\">Changelog</a></p></li></ul></div>
|
||||
<div class=\\"sidebar-nav\\" role=\\"navigation\\" aria-label=\\"primary\\"><ul><li><p>Getting started</p><ul><li><a href=\\"#/quickstart\\">Quick start</a></li><li><a href=\\"#/more-pages\\">Writing more pages</a></li><li><a href=\\"#/custom-navbar\\">Custom navbar</a></li><li><a href=\\"#/cover\\">Cover page</a></li></ul></li><li><p>Customization</p><ul><li><a href=\\"#/configuration\\">Configuration</a></li><li><a href=\\"#/themes\\">Themes</a></li><li><a href=\\"#/plugins\\">List of Plugins</a></li><li><a href=\\"#/write-a-plugin\\">Write a Plugin</a></li><li><a href=\\"#/markdown\\">Markdown configuration</a></li><li><a href=\\"#/language-highlight\\">Language highlighting</a></li><li><a href=\\"#/emoji\\">Emoji</a></li></ul></li><li><p>Guide</p><ul><li><a href=\\"#/deploy\\">Deploy</a></li><li><a href=\\"#/helpers\\">Helpers</a></li><li><a href=\\"#/vue\\">Vue compatibility</a></li><li><a href=\\"#/cdn\\">CDN</a></li><li><a href=\\"#/pwa\\">Offline Mode (PWA)</a></li><li><a href=\\"#/embed-files\\">Embed Files</a></li></ul></li><li><p><a href=\\"#/awesome\\">Awesome docsify</a></p></li><li><p><a href=\\"#/changelog\\">Changelog</a></p></li></ul></div>
|
||||
</aside>"
|
||||
`;
|
||||
|
||||
@ -11,7 +11,6 @@ describe('Docsify', function () {
|
||||
expect(vm).toBeInstanceOf(Object);
|
||||
expect(vm.constructor.name).toBe('Docsify');
|
||||
expect(vm.$fetch).toBeInstanceOf(Function);
|
||||
expect(vm.$resetEvents).toBeInstanceOf(Function);
|
||||
expect(vm.route).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user