Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-docs.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--content/assets/javascripts/docs.js98
-rw-r--r--content/assets/stylesheets/toc.scss17
-rw-r--r--content/frontend/default/components/collapsible_container.vue86
-rw-r--r--content/frontend/default/components/table_of_contents.vue80
-rw-r--r--content/frontend/default/components/table_of_contents_list.vue19
-rw-r--r--content/frontend/default/default.js3
-rw-r--r--content/frontend/default/directives/stick_to_footer.js43
-rw-r--r--content/frontend/default/setup_table_of_contents.js53
-rw-r--r--content/frontend/shared/dom.js22
-rw-r--r--content/frontend/shared/dom_parse_toc.js49
-rw-r--r--layouts/default.html8
-rw-r--r--spec/frontend/default/components/__snapshots__/table_of_contents_list_spec.js.snap7
-rw-r--r--spec/frontend/default/components/__snapshots__/table_of_contents_spec.js.snap137
-rw-r--r--spec/frontend/default/components/collapsible_container_spec.js124
-rw-r--r--spec/frontend/default/components/table_of_contents_list_spec.js46
-rw-r--r--spec/frontend/default/components/table_of_contents_spec.js102
-rw-r--r--spec/frontend/shared/dom_parse_toc_spec.js18
-rw-r--r--spec/frontend/shared/dom_spec.js32
-rw-r--r--spec/frontend/shared/toc_helper.js30
19 files changed, 867 insertions, 107 deletions
diff --git a/content/assets/javascripts/docs.js b/content/assets/javascripts/docs.js
index 018c0bb2..e19a305f 100644
--- a/content/assets/javascripts/docs.js
+++ b/content/assets/javascripts/docs.js
@@ -1,5 +1,5 @@
---
-version: 1
+version: 2
---
var NAV_INLINE_BREAKPOINT = 1100;
@@ -36,7 +36,6 @@ function toggleNavigation() {
// move document nav to sidebar
(function() {
var timeofday = document.getElementById('timeofday');
- var tocList = document.querySelector('.js-article-content > ul#markdown-toc');
var main = document.querySelector('.js-main-wrapper');
// Set timeofday var depending on the time //
@@ -58,93 +57,6 @@ function toggleNavigation() {
}
}
- // if the document has a top level nav
- if (tocList) {
- // append to the sidebar
- var sidebar = document.getElementById('doc-nav');
-
- if (sidebar) {
- // if there are items
- if (tocList.children.length >= 1) {
- var menu = tocList;
- $(tocList).addClass('nav nav-pills flex-column');
- $(tocList).find('ul').addClass('nav nav-pills flex-column');
- $(tocList).find('a').addClass('nav-link');
-
- // grab the h1's li anchor text
- var title = document.createElement('h4');
- title.innerHTML = 'On this page:';
-
- // add the text as a title
- menu.insertBefore(title, menu.children[0]);
-
- var hasHelpSection = document.getElementById('help-and-feedback');
-
- // Adds help section anchor to the ToC sidebar
- if(hasHelpSection) {
- var listItem = document.createElement('li');
- var anchor = document.createElement('a');
- var separator = document.createElement('hr');
-
- anchor.className = 'nav-link';
- anchor.innerHTML = 'Help and feedback';
- anchor.setAttribute('href', '#help-and-feedback');
- listItem.appendChild(anchor);
-
- menu.insertBefore(separator, menu.children[menu.children.length]);
- menu.insertBefore(listItem, menu.children[menu.children.length]);
- }
-
- sidebar.appendChild(menu);
-
- var sidebarContent = sidebar.querySelector('ul');
- var sidebarContentHeight = 0;
-
- // remove whitespace between elements to prevent list spacing issues
- sidebarContent.innerHTML = sidebarContent.innerHTML.replace(
- new RegExp('>[s\r\n]+<', 'g'),
- '><'
- );
-
- // When we scroll down to the bottom, we don't want the footer covering
- // the TOC list (sticky behavior)
- document.addEventListener(
- 'scroll',
- function() {
- // Wait a cycle for the dimensions to kick in
- if (!sidebarContentHeight) {
- sidebarContentHeight =
- sidebarContent.getBoundingClientRect().height + 55;
- }
-
- var isTouchingBottom = false;
- if (window.innerWidth >= NAV_INLINE_BREAKPOINT) {
- isTouchingBottom =
- window.scrollY + sidebarContentHeight >= main.offsetHeight;
- }
-
- if (isTouchingBottom) {
- sidebarContent.style.top =
- main.offsetHeight -
- (window.scrollY + sidebarContentHeight) +
- 'px';
- } else {
- sidebarContent.style.top = '';
- }
- },
- { passive: true }
- );
- }
- }
-
- // main content has-toc
- if (main && main.classList) {
- main.classList.add('has-toc');
- } else {
- main.className += ' has-toc';
- }
- }
-
document.addEventListener('DOMContentLoaded', function() {
var globalNav = document.getElementById('global-nav');
var media = window.matchMedia('(max-width: 1099px)');
@@ -169,14 +81,6 @@ function toggleNavigation() {
}
});
- if (media.matches) {
- var el = document.getElementById('markdown-toc');
- el.classList.add('collapse');
- el.classList.add('out');
- el.style.height = '34px';
- el.previousElementSibling.classList.add('collapsed');
- }
-
// Adds the ability to auto-scroll to the active item in the TOC
$(window).on('activate.bs.scrollspy', function() {
const isMobile = window.matchMedia('(max-width: 1099px)').matches;
diff --git a/content/assets/stylesheets/toc.scss b/content/assets/stylesheets/toc.scss
index 5480bf2f..6b6fb00b 100644
--- a/content/assets/stylesheets/toc.scss
+++ b/content/assets/stylesheets/toc.scss
@@ -1,5 +1,5 @@
---
-version: 8
+version: 9
---
@import "variables";
@@ -15,7 +15,7 @@ version: 8
font-size: 16px;
}
- > ul {
+ .table-of-contents {
position: relative;
padding: 16px 8px;
margin-top: 60px;
@@ -32,7 +32,6 @@ version: 8
font-size: 14px;
color: $toc-link-color;
display: inline;
- padding: 3px 7px;
&:hover {
color: $link-color-nav-hover;
@@ -112,11 +111,21 @@ version: 8
}
// ToC toggle button
- > ul {
+ .table-of-contents {
margin: 0;
}
}
+ .sm-collapsing {
+ height: 0;
+ overflow: hidden;
+ transition: height .2s ease;
+ }
+
+ .sm-collapsed {
+ display: none;
+ }
+
.main.class {
float: none;
width: inherit;
diff --git a/content/frontend/default/components/collapsible_container.vue b/content/frontend/default/components/collapsible_container.vue
new file mode 100644
index 00000000..59653ab8
--- /dev/null
+++ b/content/frontend/default/components/collapsible_container.vue
@@ -0,0 +1,86 @@
+<script>
+import { getOuterHeight } from '../../shared/dom';
+
+export default {
+ name: 'CollapsibleContainer',
+ model: {
+ prop: 'isCollapsed',
+ event: 'change',
+ },
+ props: {
+ isCollapsed: {
+ type: Boolean,
+ required: true,
+ },
+ collapsingClass: {
+ type: String,
+ required: false,
+ default: 'sm-collapsing',
+ },
+ collapsedClass: {
+ type: String,
+ required: false,
+ default: 'sm-collapsed',
+ },
+ },
+ data() {
+ return {
+ isCollapsing: false,
+ collapsingHeight: 0,
+ };
+ },
+ computed: {
+ styles() {
+ if (this.isCollapsing) {
+ return {
+ height: `${this.collapsingHeight}px`,
+ };
+ }
+
+ return {};
+ },
+ classes() {
+ if (this.isCollapsing) {
+ return this.collapsingClass;
+ }
+ if (this.isCollapsed) {
+ return this.collapsedClass;
+ }
+
+ return '';
+ },
+ },
+ methods: {
+ collapse(shouldCollapse) {
+ if (this.isCollapsing) {
+ return;
+ }
+ // Right away let's flag that we're collapsing so we don't accept anymore updates
+ this.isCollapsing = true;
+
+ // Let's let our parent go ahead and treat us as collapsed.
+ this.$emit('change', shouldCollapse);
+
+ // Get start/stop height based on if we're collapsing or expanding
+ const containerHeight = getOuterHeight(this.$el);
+ const startHeight = shouldCollapse ? containerHeight : 0;
+ const stopHeight = shouldCollapse ? 0 : containerHeight;
+
+ // Kick off transition
+ this.collapsingHeight = startHeight;
+ setTimeout(() => {
+ this.collapsingHeight = stopHeight;
+ }, 50);
+
+ setTimeout(() => {
+ this.isCollapsing = false;
+ }, 400);
+ },
+ },
+};
+</script>
+<template>
+ <div :class="classes" :style="styles">
+ <slot></slot>
+ </div>
+</template>
diff --git a/content/frontend/default/components/table_of_contents.vue b/content/frontend/default/components/table_of_contents.vue
new file mode 100644
index 00000000..90e798fb
--- /dev/null
+++ b/content/frontend/default/components/table_of_contents.vue
@@ -0,0 +1,80 @@
+<script>
+import TableOfContentsList from './table_of_contents_list.vue';
+import CollapsibleContainer from './collapsible_container.vue';
+
+export default {
+ name: 'TableOfContents',
+ components: {
+ CollapsibleContainer,
+ TableOfContentsList,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ helpAndFeedbackId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasHelpAndFeedback: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isCollapsed: true,
+ };
+ },
+ computed: {
+ helpAndFeedbackItems() {
+ return [
+ {
+ text: 'Help and feedback',
+ href: `#${this.helpAndFeedbackId}`,
+ id: null,
+ items: [],
+ },
+ ];
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.$refs.container.collapse(!this.isCollapsed);
+ },
+ },
+};
+</script>
+<template>
+ <div id="markdown-toc" class="table-of-contents-container position-relative">
+ <a
+ class="toc-collapse"
+ :class="{ collapsed: isCollapsed }"
+ role="button"
+ href="#"
+ :aria-expanded="!isCollapsed"
+ aria-controls="markdown-toc"
+ data-testid="collapse"
+ @click.prevent="toggleCollapse"
+ ></a>
+ <collapsible-container
+ ref="container"
+ v-model="isCollapsed"
+ class="table-of-contents"
+ data-testid="container"
+ >
+ <h4>On this page:</h4>
+ <table-of-contents-list :items="items" class="my-0" data-testid="main-list" />
+ <div v-if="hasHelpAndFeedback" class="border-top mt-3 pt-3">
+ <table-of-contents-list
+ :items="helpAndFeedbackItems"
+ class="my-0"
+ data-testid="help-and-feedback"
+ />
+ </div>
+ </collapsible-container>
+ </div>
+</template>
diff --git a/content/frontend/default/components/table_of_contents_list.vue b/content/frontend/default/components/table_of_contents_list.vue
new file mode 100644
index 00000000..98fb8599
--- /dev/null
+++ b/content/frontend/default/components/table_of_contents_list.vue
@@ -0,0 +1,19 @@
+<script>
+export default {
+ name: 'TableOfContentsList',
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <ul class="nav nav-pills flex-column">
+ <li v-for="(item, index) in items" :key="`${item.text}_${index}`">
+ <a :id="item.id" class="nav-link" :href="item.href">{{ item.text }}</a>
+ <table-of-contents-list v-if="item.items && item.items.length" :items="item.items" />
+ </li>
+ </ul>
+</template>
diff --git a/content/frontend/default/default.js b/content/frontend/default/default.js
index 447bf7f1..a9e9ea52 100644
--- a/content/frontend/default/default.js
+++ b/content/frontend/default/default.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import setupTableOfContents from './setup_table_of_contents';
import NavigationToggle from './components/navigation_toggle.vue';
import VersionBanner from './components/version_banner.vue';
@@ -40,4 +41,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
+
+ setupTableOfContents();
});
diff --git a/content/frontend/default/directives/stick_to_footer.js b/content/frontend/default/directives/stick_to_footer.js
new file mode 100644
index 00000000..add7201e
--- /dev/null
+++ b/content/frontend/default/directives/stick_to_footer.js
@@ -0,0 +1,43 @@
+const NAV_INLINE_BREAKPOINT = 1100;
+const NAV_TOP_MARGIN = 55;
+
+const isTouchingBottom = (height, offsetHeight) => {
+ if (window.innerWidth < NAV_INLINE_BREAKPOINT) {
+ return false;
+ }
+
+ return offsetHeight <= window.scrollY + height;
+};
+
+const getTopOffset = (height, offsetHeight) => {
+ if (isTouchingBottom(height, offsetHeight)) {
+ return offsetHeight - (window.scrollY + height);
+ }
+
+ return 0;
+};
+
+export default {
+ bind(el, { value }) {
+ let contentHeight;
+ const mainEl = document.querySelector(value);
+
+ el.$_stickToFooter_listener = () => {
+ if (!contentHeight) {
+ contentHeight = el.getBoundingClientRect().height + NAV_TOP_MARGIN;
+ }
+ const { offsetHeight } = mainEl;
+ const topOffset = getTopOffset(contentHeight, offsetHeight);
+
+ el.style.top = topOffset < 0 ? `${topOffset}px` : '';
+ };
+
+ // When we scroll down to the bottom, we don't want the footer covering
+ // the TOC list (sticky behavior)
+ document.addEventListener('scroll', el.$_stickToFooter_listener, { passive: true });
+ },
+ unbind(el) {
+ el.style.top = '';
+ document.removeEventListener('scroll', el.$_stickToFooter_listener);
+ },
+};
diff --git a/content/frontend/default/setup_table_of_contents.js b/content/frontend/default/setup_table_of_contents.js
new file mode 100644
index 00000000..8fb5dc69
--- /dev/null
+++ b/content/frontend/default/setup_table_of_contents.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import TableOfContents from './components/table_of_contents.vue';
+import StickToFooter from './directives/stick_to_footer';
+import { parseTOC } from '../shared/dom_parse_toc';
+
+const SIDEBAR_ID = 'doc-nav';
+const MARKDOWN_TOC_ID = 'markdown-toc';
+const HELP_AND_FEEDBACK_ID = 'help-and-feedback';
+const MAIN_SELECTOR = '.js-main-wrapper';
+
+export default () => {
+ const sidebar = document.getElementById(SIDEBAR_ID);
+ const menu = document.getElementById(MARKDOWN_TOC_ID);
+ const main = document.querySelector(MAIN_SELECTOR);
+ const hasHelpAndFeedback = Boolean(document.getElementById(HELP_AND_FEEDBACK_ID));
+
+ if (!sidebar || !menu) {
+ return null;
+ }
+
+ if (main) {
+ main.classList.add('has-toc');
+ }
+
+ const items = parseTOC(menu);
+ menu.remove();
+
+ const el = document.createElement('div');
+ sidebar.appendChild(el);
+
+ return new Vue({
+ el,
+ directives: {
+ StickToFooter,
+ },
+ render(h) {
+ return h(TableOfContents, {
+ props: {
+ items,
+ helpAndFeedbackId: HELP_AND_FEEDBACK_ID,
+ hasHelpAndFeedback,
+ },
+ directives: [
+ {
+ name: 'stick-to-footer',
+ value: MAIN_SELECTOR,
+ expression: MAIN_SELECTOR,
+ },
+ ],
+ });
+ },
+ });
+};
diff --git a/content/frontend/shared/dom.js b/content/frontend/shared/dom.js
new file mode 100644
index 00000000..d041ac09
--- /dev/null
+++ b/content/frontend/shared/dom.js
@@ -0,0 +1,22 @@
+/* global $ */
+
+/**
+ * Returns outerHeight of element **even if it's hidden**
+ *
+ * NOTE: Uses jQuery because there is no trivial way to do this in
+ * vaniall JS, and it's nice that jQuery has a reliable out-of-the-box
+ * solution.
+ *
+ * @param {Element} el
+ */
+export const getOuterHeight = el => $(el).outerHeight();
+
+/**
+ * Find the first child of the given element with the given tag name
+ *
+ * @param {Element} el
+ * @param {String} tagName
+ * @returns {Element | null} Returns first child that matches the given tagName (or null if not found)
+ */
+export const findChildByTagName = (el, tagName) =>
+ Array.from(el.childNodes).find(x => x.tagName === tagName.toUpperCase());
diff --git a/content/frontend/shared/dom_parse_toc.js b/content/frontend/shared/dom_parse_toc.js
new file mode 100644
index 00000000..552550a1
--- /dev/null
+++ b/content/frontend/shared/dom_parse_toc.js
@@ -0,0 +1,49 @@
+/* eslint-disable import/prefer-default-export */
+import { findChildByTagName } from './dom';
+
+const TAG_LI = 'LI';
+const TAG_A = 'A';
+const TAG_UL = 'UL';
+
+/**
+ * Parses the given HTML Table of Contents into a data structure
+ *
+ * ```
+ * type Item = { text: String, href: String, id: String, items: Item[] }
+ *
+ * parseTOC: Element => Item[]
+ * ```
+ *
+ * @param {Element} menu Parent <ul> element
+ */
+export const parseTOC = menu => {
+ const items = [];
+
+ if (!menu) {
+ return items;
+ }
+
+ menu.childNodes.forEach(li => {
+ if (li.tagName !== TAG_LI) {
+ return;
+ }
+
+ const link = findChildByTagName(li, TAG_A);
+ const subMenu = findChildByTagName(li, TAG_UL);
+
+ if (!link) {
+ return;
+ }
+
+ const item = {
+ text: link.textContent,
+ href: link.getAttribute('href'),
+ id: link.id,
+ items: parseTOC(subMenu),
+ };
+
+ items.push(item);
+ });
+
+ return items;
+};
diff --git a/layouts/default.html b/layouts/default.html
index b4633c45..20c90920 100644
--- a/layouts/default.html
+++ b/layouts/default.html
@@ -28,13 +28,9 @@
<% end %>
<li class="breadcrumb"><%= @item.key?(:title) ? "#{@item[:title]}" : "Current page" %></li>
</ul>
- <div id="doc-nav" class="doc-nav">
- <a class="toc-collapse" data-toggle="collapse" href="#markdown-toc" aria-expanded="true" aria-controls="markdown-toc"></a>
- </div>
+ <div id="doc-nav" class="doc-nav"></div>
<% else %>
- <div id="doc-nav" class="doc-nav toc-no-breadcrumbs">
- <a class="toc-collapse" role="button" data-toggle="collapse" href="#markdown-toc" aria-expanded="true" aria-controls="markdown-toc"></a>
- </div>
+ <div id="doc-nav" class="doc-nav toc-no-breadcrumbs"></div>
<% end %>
<% end %>
<% if @item[:title] %>
diff --git a/spec/frontend/default/components/__snapshots__/table_of_contents_list_spec.js.snap b/spec/frontend/default/components/__snapshots__/table_of_contents_list_spec.js.snap
new file mode 100644
index 00000000..6a00c5ea
--- /dev/null
+++ b/spec/frontend/default/components/__snapshots__/table_of_contents_list_spec.js.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`frontend/default/components/table_of_contents_list with empty items shows empty ul 1`] = `
+<ul
+ class="nav nav-pills flex-column"
+/>
+`;
diff --git a/spec/frontend/default/components/__snapshots__/table_of_contents_spec.js.snap b/spec/frontend/default/components/__snapshots__/table_of_contents_spec.js.snap
new file mode 100644
index 00000000..6483dea6
--- /dev/null
+++ b/spec/frontend/default/components/__snapshots__/table_of_contents_spec.js.snap
@@ -0,0 +1,137 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`frontend/default/components/table_of_contents matches snapshot 1`] = `
+<div
+ class="table-of-contents-container position-relative"
+ id="markdown-toc"
+>
+ <a
+ aria-controls="markdown-toc"
+ class="toc-collapse collapsed"
+ data-testid="collapse"
+ href="#"
+ role="button"
+ />
+
+ <div
+ class="table-of-contents sm-collapsed"
+ data-testid="container"
+ >
+ <h4>
+ On this page:
+ </h4>
+
+ <ul
+ class="nav nav-pills flex-column my-0"
+ data-testid="main-list"
+ >
+ <li>
+ <a
+ class="nav-link"
+ href="#lorem"
+ id="Lorem-anchor"
+ >
+ Lorem
+ </a>
+
+ <ul
+ class="nav nav-pills flex-column"
+ >
+ <li>
+ <a
+ class="nav-link"
+ href="#lorem-"
+ id="Lorem 2-anchor"
+ >
+ Lorem 2
+ </a>
+
+ <!---->
+ </li>
+ </ul>
+ </li>
+ <li>
+ <a
+ class="nav-link"
+ href="#ipsum"
+ id="Ipsum-anchor"
+ >
+ Ipsum
+ </a>
+
+ <ul
+ class="nav nav-pills flex-column"
+ >
+ <li>
+ <a
+ class="nav-link"
+ href="#dolar"
+ id="Dolar-anchor"
+ >
+ Dolar
+ </a>
+
+ <ul
+ class="nav nav-pills flex-column"
+ >
+ <li>
+ <a
+ class="nav-link"
+ href="#sit"
+ id="Sit-anchor"
+ >
+ Sit
+ </a>
+
+ <!---->
+ </li>
+ <li>
+ <a
+ class="nav-link"
+ href="#amit"
+ id="Amit-anchor"
+ >
+ Amit
+ </a>
+
+ <!---->
+ </li>
+ <li>
+ <a
+ class="nav-link"
+ href="#test"
+ id="Test-anchor"
+ >
+ Test
+ </a>
+
+ <!---->
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+
+ <div
+ class="border-top mt-3 pt-3"
+ >
+ <ul
+ class="nav nav-pills flex-column my-0"
+ data-testid="help-and-feedback"
+ >
+ <li>
+ <a
+ class="nav-link"
+ href="#test-help-and-feedback"
+ >
+ Help and feedback
+ </a>
+
+ <!---->
+ </li>
+ </ul>
+ </div>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/default/components/collapsible_container_spec.js b/spec/frontend/default/components/collapsible_container_spec.js
new file mode 100644
index 00000000..259cdf8c
--- /dev/null
+++ b/spec/frontend/default/components/collapsible_container_spec.js
@@ -0,0 +1,124 @@
+import { shallowMount } from '@vue/test-utils';
+import CollapsibleContainer from '../../../../content/frontend/default/components/collapsible_container.vue';
+import * as dom from '../../../../content/frontend/shared/dom';
+
+const TEST_COLLAPSING_CLASS = 'test-collapsing';
+const TEST_COLLAPSED_CLASS = 'test-collapsed';
+const TEST_SLOT = 'Lorem ipsum dolar sit amit';
+const TEST_OUTER_HEIGHT = 400;
+const KICKOFF_DELAY = 50;
+const FINISH_DELAY = 400;
+
+describe('frontend/default/components/collapsible_container', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ // jquery is not available in Jest yet so we need to mock this method
+ jest.spyOn(dom, 'getOuterHeight').mockImplementation(x => Number(x.dataset.testOuterHeight));
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ jest.useRealTimers();
+ });
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(CollapsibleContainer, {
+ propsData: {
+ collapsingClass: TEST_COLLAPSING_CLASS,
+ collapsedClass: TEST_COLLAPSED_CLASS,
+ ...props,
+ },
+ slots: {
+ default: TEST_SLOT,
+ },
+ attrs: {
+ 'data-test-outer-height': TEST_OUTER_HEIGHT.toString(),
+ },
+ });
+ };
+ const findStyleHeight = () => wrapper.element.style.height;
+ const waitForKickoff = () => jest.advanceTimersByTime(KICKOFF_DELAY);
+ const waitForTransition = () => jest.advanceTimersByTime(FINISH_DELAY - KICKOFF_DELAY);
+
+ describe.each`
+ isCollapsed | startHeight | endHeight | startClasses | endClasses
+ ${true} | ${0} | ${TEST_OUTER_HEIGHT} | ${[TEST_COLLAPSED_CLASS]} | ${[]}
+ ${false} | ${TEST_OUTER_HEIGHT} | ${0} | ${[]} | ${[TEST_COLLAPSED_CLASS]}
+ `(
+ 'when isCollapsed = $isCollapsed',
+ ({ isCollapsed, startHeight, endHeight, startClasses, endClasses }) => {
+ beforeEach(() => {
+ createComponent({ isCollapsed });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders slot', () => {
+ expect(wrapper.text()).toBe(TEST_SLOT);
+ });
+
+ it('has starting classes', () => {
+ expect(wrapper.classes()).toEqual(startClasses);
+ });
+
+ it('has not emitted anything', () => {
+ expect(wrapper.emitted()).toEqual({});
+ });
+
+ describe('when collapse is triggered', () => {
+ beforeEach(() => {
+ wrapper.vm.collapse(!isCollapsed);
+
+ // set props because this is what would naturally happen with `v-model`
+ wrapper.setProps({ isCollapsed: !isCollapsed });
+ });
+
+ it('has collapsing class', () => {
+ expect(wrapper.classes()).toEqual([TEST_COLLAPSING_CLASS]);
+ });
+
+ it('emits change', () => {
+ expect(wrapper.emitted().change).toEqual([[!isCollapsed]]);
+ });
+
+ it('sets starting height', () => {
+ expect(findStyleHeight()).toEqual(`${startHeight}px`);
+ });
+
+ it('triggering collapse again does not do anything', () => {
+ wrapper.vm.collapse(isCollapsed);
+
+ expect(wrapper.emitted().change).toEqual([[!isCollapsed]]);
+ });
+
+ describe('after animation kickoff delay', () => {
+ beforeEach(() => {
+ waitForKickoff();
+ });
+
+ it('sets ending height', () => {
+ expect(findStyleHeight()).toEqual(`${endHeight}px`);
+ });
+
+ describe('after transition', () => {
+ beforeEach(() => {
+ waitForTransition();
+ });
+
+ it('does not set height', () => {
+ expect(findStyleHeight()).toBe('');
+ });
+
+ it('sets ending classes', () => {
+ expect(wrapper.classes()).toEqual(endClasses);
+ });
+ });
+ });
+ });
+ },
+ );
+});
diff --git a/spec/frontend/default/components/table_of_contents_list_spec.js b/spec/frontend/default/components/table_of_contents_list_spec.js
new file mode 100644
index 00000000..2d69f880
--- /dev/null
+++ b/spec/frontend/default/components/table_of_contents_list_spec.js
@@ -0,0 +1,46 @@
+import { mount } from '@vue/test-utils';
+import { parseTOC } from '../../../../content/frontend/shared/dom_parse_toc';
+import TableOfContentsList from '../../../../content/frontend/default/components/table_of_contents_list.vue';
+import { createExampleToc } from '../../shared/toc_helper';
+
+describe('frontend/default/components/table_of_contents_list', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(TableOfContentsList, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ const findItemsData = () => parseTOC(wrapper.element);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('with items', () => {
+ let items;
+
+ beforeEach(() => {
+ items = createExampleToc();
+ createComponent({ items });
+ });
+
+ it('renders all items', () => {
+ expect(findItemsData()).toEqual(items);
+ });
+ });
+
+ describe('with empty items', () => {
+ beforeEach(() => {
+ createComponent({ items: [] });
+ });
+
+ it('shows empty ul', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/default/components/table_of_contents_spec.js b/spec/frontend/default/components/table_of_contents_spec.js
new file mode 100644
index 00000000..a256cae5
--- /dev/null
+++ b/spec/frontend/default/components/table_of_contents_spec.js
@@ -0,0 +1,102 @@
+import { shallowMount, mount } from '@vue/test-utils';
+import TableOfContents from '../../../../content/frontend/default/components/table_of_contents.vue';
+import * as dom from '../../../../content/frontend/shared/dom';
+import { createExampleToc } from '../../shared/toc_helper';
+
+const TEST_ITEMS = createExampleToc();
+const TEST_HELP_AND_FEEDBACK_ID = 'test-help-and-feedback';
+
+describe('frontend/default/components/table_of_contents', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ beforeEach(() => {
+ // jquery is not available in Jest yet so we need to mock this method
+ jest.spyOn(dom, 'getOuterHeight').mockReturnValue(100);
+ });
+
+ const createComponent = (props = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(TableOfContents, {
+ propsData: {
+ items: TEST_ITEMS,
+ helpAndFeedbackId: TEST_HELP_AND_FEEDBACK_ID,
+ ...props,
+ },
+ });
+ };
+
+ const findCollapseButton = () => wrapper.find('[data-testid="collapse"]');
+ const findCollapsibleContainer = () => wrapper.find('[data-testid="container"]');
+ const findMainList = () => wrapper.find('[data-testid="main-list"]');
+ const findHelpAndFeedback = () => wrapper.find('[data-testid="help-and-feedback"]');
+ const clickCollapseButton = () => findCollapseButton().trigger('click');
+
+ it('matches snapshot', () => {
+ createComponent({ hasHelpAndFeedback: true }, mount);
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('with hasHelpAndFeedback', () => {
+ beforeEach(() => {
+ createComponent({ hasHelpAndFeedback: true });
+ });
+
+ it('shows help and feedback', () => {
+ expect(findHelpAndFeedback().exists()).toBe(true);
+ expect(findHelpAndFeedback().props('items')).toEqual([
+ {
+ href: `#${TEST_HELP_AND_FEEDBACK_ID}`,
+ id: null,
+ items: [],
+ text: 'Help and feedback',
+ },
+ ]);
+ });
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent({}, mount);
+ });
+
+ it('does not show help and feedback', () => {
+ expect(findHelpAndFeedback().exists()).toBe(false);
+ });
+
+ it('renders toc list', () => {
+ expect(findMainList().props('items')).toEqual(TEST_ITEMS);
+ });
+
+ it('is initially collapsed', () => {
+ expect(findCollapseButton().classes('collapsed')).toBe(true);
+ expect(findCollapsibleContainer().classes('sm-collapsed')).toBe(true);
+ });
+
+ describe('when collapse button is pressed', () => {
+ beforeEach(() => {
+ clickCollapseButton();
+ });
+
+ it('starts expanding', () => {
+ expect(findCollapsibleContainer().classes('sm-collapsing')).toBe(true);
+ });
+
+ it('updates button class', () => {
+ expect(findCollapseButton().classes('collapsed')).toBe(false);
+ });
+
+ it('when button pressed again, nothing happens because in the middle of collapsing', () => {
+ clickCollapseButton();
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findCollapsibleContainer().classes('sm-collapsing')).toBe(true);
+ expect(findCollapseButton().classes('collapsed')).toBe(false);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/shared/dom_parse_toc_spec.js b/spec/frontend/shared/dom_parse_toc_spec.js
new file mode 100644
index 00000000..adcacb90
--- /dev/null
+++ b/spec/frontend/shared/dom_parse_toc_spec.js
@@ -0,0 +1,18 @@
+import { parseTOC } from '../../../content/frontend/shared/dom_parse_toc';
+import { createItem, createTOCElement, createExampleToc } from './toc_helper';
+
+describe('frontend/shared/dom_parse_toc', () => {
+ it('parses nested HTML list', () => {
+ const list = createExampleToc();
+ const el = createTOCElement(list);
+
+ expect(parseTOC(el)).toEqual(list);
+ });
+
+ it('skips items that do not have links', () => {
+ const list = [createItem('Lorem'), { items: [createItem('no link')] }, createItem('Ipsum')];
+ const el = createTOCElement(list);
+
+ expect(parseTOC(el)).toEqual([createItem('Lorem'), createItem('Ipsum')]);
+ });
+});
diff --git a/spec/frontend/shared/dom_spec.js b/spec/frontend/shared/dom_spec.js
new file mode 100644
index 00000000..ceb09708
--- /dev/null
+++ b/spec/frontend/shared/dom_spec.js
@@ -0,0 +1,32 @@
+import { findChildByTagName } from '../../../content/frontend/shared/dom';
+
+describe('frontend/shared/dom', () => {
+ const createElementWithChildren = children => {
+ const el = document.createElement('div');
+
+ children.forEach(tag => {
+ const child = document.createElement(tag);
+ el.appendChild(child);
+ });
+
+ return el;
+ };
+
+ describe('findChildByTagName', () => {
+ it.each`
+ children | tagName | expectedIndex
+ ${['div', 'p', 'ul', 'p']} | ${'p'} | ${1}
+ ${['div', 'ul']} | ${'p'} | ${-1}
+ ${['li', 'li', 'li']} | ${'li'} | ${0}
+ ${[]} | ${'li'} | ${-1}
+ `(
+ 'with children $children and $tagName, returns $expectedIndex child',
+ ({ children, tagName, expectedIndex }) => {
+ const el = createElementWithChildren(children);
+ const expectedChild = el.childNodes[expectedIndex];
+
+ expect(findChildByTagName(el, tagName)).toBe(expectedChild);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/shared/toc_helper.js b/spec/frontend/shared/toc_helper.js
new file mode 100644
index 00000000..574f1d17
--- /dev/null
+++ b/spec/frontend/shared/toc_helper.js
@@ -0,0 +1,30 @@
+export const createItem = (text, items = []) => ({
+ text,
+ href: `#${text.replace(/[^a-zA-Z-]+/g, '-').toLowerCase()}`,
+ id: `${text}-anchor`,
+ items,
+});
+
+export const buildHTML = list =>
+ list
+ .map(
+ ({ text, href, id, items }) =>
+ `<li>
+${text ? `<a href="${href}" id="${id}">${text}</a>` : ''}
+${items?.length ? `<ul>${buildHTML(items)}</ul>` : ''}
+</li>`,
+ )
+ .join('');
+
+export const createTOCElement = list => {
+ const ul = document.createElement('ul');
+ ul.innerHTML = buildHTML(list);
+ return ul;
+};
+
+export const createExampleToc = () => [
+ createItem('Lorem', [createItem('Lorem 2')]),
+ createItem('Ipsum', [
+ createItem('Dolar', [createItem('Sit'), createItem('Amit'), createItem('Test')]),
+ ]),
+];