Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions src/pat/autotoc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,33 @@ Automatically create a table of contents.

## Configuration

| Option | Type | Default | Description |
| :------------------: | :----: | :---------------: | :-----------------------------------: |
| IDPrefix | string | 'autotoc-item-' | Prefix used to generate ID. |
| classActiveName | string | 'active' | Class used for active level. |
| classLevelPrefixName | string | 'autotoc-level-' | Class prefix used for the TOC levels. |
| classSectionName | string | 'autotoc-section' | Class used for section in TOC. |
| classTOCName | string | 'autotoc-nav' | Class used for the TOC. |
| levels | string | 'h1,h2,h3' | Selectors used to find levels. |
| scrollDuration | string | 'slow' | Speed of scrolling. |
| scrollEasing | string | 'swing' | Easing to use while scrolling. |
| section | string | 'section' | Tag type to use for TOC. |
| Option | Type | Default | Description |
| :------------------: | :----: | :---------------: | :-------------------------------------------------------------------------: |
| IDPrefix | string | 'autotoc-item-' | Prefix used to generate ID. |
| classActiveName | string | 'active' | Class used for active level. |
| classLevelPrefixName | string | 'autotoc-level-' | Class prefix used for the TOC levels. |
| classSectionName | string | 'autotoc-section' | Class used for section in TOC. |
| classTOCName | string | 'autotoc-nav' | Class used for the TOC. |
| levels | string | 'h1,h2,h3' | Selectors used to find levels. |
| scrollDuration | string | 'slow' | Speed of scrolling. |
| scrollEasing | string | 'swing' | Easing to use while scrolling. |
| section | string | 'section' | Tag type to use for TOC. |
| validationDelay | number | 200 | For tabbed forms: Delay time in ms after the validation marker gets active. |

## Validation support for tabbed forms

In case the autotoc pattern is used for tabbed forms together with
pat-validation (a quite common case for z3c forms in Plone) the autotoc
navigation items are marked with `required` and `invalid` classes, if
applicable.

This allows for a quick overview in which tabs required input fields or invalid
data is present.

The option `validationDelay` is set to twice the delay of pat-validation input
check delay. The default is 200ms after which the form's tab-navigation is
marked with the required and invalid CSS classes.


## Examples

Expand Down
67 changes: 61 additions & 6 deletions src/pat/autotoc/autotoc.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import $ from "jquery";
import Base from "@patternslib/patternslib/src/core/base";
import utils from "@patternslib/patternslib/src/core/utils";

export default Base.extend({
name: "autotoc",
Expand All @@ -15,7 +16,11 @@ export default Base.extend({
classActiveName: "active",
scrollDuration: "slow",
scrollEasing: "swing",
validationDelay: 200, // Note: This is set to twice as the default delay time for validation.
},

tabs: [],

init: function () {
import("./autotoc.scss");

Expand All @@ -42,10 +47,10 @@ export default Base.extend({
var activeId = null;

$(self.options.levels, self.$el).each(function (i) {
var $level = $(this),
id = $level.prop("id")
? $level.prop("id")
: $level.parents(self.options.section).prop("id");
const section = this.closest(self.options.section);
const $level = $(this);
let id = $level.prop("id") ? $level.prop("id") : $(section).prop("id");

if (!id || $("#" + id).length > 0) {
id = self.options.IDPrefix + self.name + "-" + i;
}
Expand All @@ -56,8 +61,8 @@ export default Base.extend({
activeId = id;
}
$level.data("navref", id);
$("<a/>")
.appendTo(self.$toc)
const $nav = $("<a/>");
$nav.appendTo(self.$toc)
.text($level.text())
.attr("id", id)
.attr("href", "#" + id)
Expand Down Expand Up @@ -107,6 +112,12 @@ export default Base.extend({
}
});
$level.data("autotoc-trigger-id", id);

self.tabs.push({
section: section,
id: id,
nav: $nav[0],
});
});

if (activeId) {
Expand All @@ -120,7 +131,51 @@ export default Base.extend({
skipHash: true,
});
}

// After DOM tree is built, initialize eventual validation
this.initialize_validation(self.$el);
},

initialize_validation: function ($el) {
const el = $el[0];

// Initialize only on pat-validation forms.
const form = el.closest("form.pat-validation");
if (!form) {
return;
}

for (const tab of this.tabs) {
if (tab.section.querySelectorAll("[required]").length > 0) {
tab.nav.classList.add("required");
} else {
tab.nav.classList.remove("required");
}
}

const debounced_validation_marker = utils.debounce(() => {
this.validation_marker();
}, this.options.validationDelay);

form.addEventListener("pat-update", (e) => {
if (e.detail?.pattern !== "validation") {
// Nothing to do.
return;
}
debounced_validation_marker();
});
},

validation_marker: function () {
for (const tab of this.tabs) {
if (tab.section.querySelectorAll(":invalid").length > 0) {
tab.nav.classList.add("invalid");
} else {
tab.nav.classList.remove("invalid");
}
}
},

getLevel: function ($el) {
var elementLevel = 0;
$.each(this.options.levels.split(","), function (level, levelSelector) {
Expand Down
130 changes: 129 additions & 1 deletion src/pat/autotoc/autotoc.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { jest } from "@jest/globals";
import "./autotoc";
import $ from "jquery";
import events from "@patternslib/patternslib/src/core/events";
import registry from "@patternslib/patternslib/src/core/registry";
import utils from "@patternslib/patternslib/src/core/utils";

describe("AutoTOC", function () {
describe("1 - AutoTOC", function () {
beforeEach(function () {
document.body.innerHTML = `
<div class="pat-autotoc">
Expand Down Expand Up @@ -138,3 +140,129 @@ describe("AutoTOC", function () {
// .trigger("click");
// });
});

describe("2 - AutoTOC with tabs", function () {
afterEach(() => {
document.body.innerHTML = "";
});

// NOTE: These tests pass individually but not together with the other tests.
// This pattern has poor isolation and needs to be rewritten using
// Patternslib BasePattern class.

it.skip("2.1 - integrates with pat-validation and marks tabs as required and/or invalid.", async function () {
// Use pat-validation to test the required and invalid classes
await import("@patternslib/patternslib/src/pat/validation/validation");

document.body.innerHTML = `
<form class="pat-validation pat-autotoc"
data-pat-autotoc="
levels: legend;
section: fieldset;
className: autotabs;
validationDelay: 0;
"
data-pat-validation="
delay: 0;
"
>
<fieldset id="fieldset-1">
<legend>Tab 1</legend>
<input name="constraint-number" type="number" max="1000"/>
</fieldset>

<fieldset id="fieldset-2">
<legend>Tab 2</legend>
<input name="constraint-required" required />
</fieldset>

<fieldset id="fieldset-3">
<legend>Tab 3</legend>
<input name="constraint-none" />
</fieldset>
</form>
`;

const inp1 = document.querySelector("input[name=constraint-number]");
const inp2 = document.querySelector("input[name=constraint-required]");
const inp3 = document.querySelector("input[name=constraint-none]");

registry.scan(document.body);
await utils.timeout(1);

const tabs = document.querySelectorAll(".autotoc-nav > a");
expect(tabs.length).toEqual(3);

expect(tabs[0].classList.contains("required")).toEqual(false);
expect(tabs[1].classList.contains("required")).toEqual(true);
expect(tabs[2].classList.contains("required")).toEqual(false);

inp1.dispatchEvent(events.change_event());
inp2.dispatchEvent(events.change_event());
inp3.dispatchEvent(events.change_event());
await utils.timeout(10);
expect(tabs[0].classList.contains("invalid")).toEqual(false);
expect(tabs[1].classList.contains("invalid")).toEqual(true);
expect(tabs[2].classList.contains("invalid")).toEqual(false);

inp1.value = "10000";
inp1.dispatchEvent(events.change_event());
inp2.dispatchEvent(events.change_event());
inp3.dispatchEvent(events.change_event());
await utils.timeout(10);
expect(tabs[0].classList.contains("invalid")).toEqual(true);
expect(tabs[1].classList.contains("invalid")).toEqual(true);
expect(tabs[2].classList.contains("invalid")).toEqual(false);

inp1.value = "123";
inp2.value = "okay";
inp1.dispatchEvent(events.change_event());
inp2.dispatchEvent(events.change_event());
inp3.dispatchEvent(events.change_event());
await utils.timeout(10);
expect(tabs[0].classList.contains("invalid")).toEqual(false);
expect(tabs[1].classList.contains("invalid")).toEqual(false);
expect(tabs[2].classList.contains("invalid")).toEqual(false);
});

it.skip("2.2 - the validation marker is called only once when a bunch of updates arrives.", async function () {
// Use pat-validation to test the required and invalid classes
await import("@patternslib/patternslib/src/pat/validation/validation");

document.body.innerHTML = `
<form class="pat-validation pat-autotoc"
data-pat-autotoc="
levels: legend;
section: fieldset;
className: autotabs;
validationDelay: 0;
"
data-pat-validation="
delay: 0;
"
>
<fieldset id="fieldset-1">
<legend>Tab 1</legend>
<input name="inp1" />
<input name="inp2" />
</fieldset>
</form>
`;

const form = document.querySelector(".pat-autotoc");
const inp1 = document.querySelector("input[name=inp1]");
const inp2 = document.querySelector("input[name=inp2]");

registry.scan(document.body);
await utils.timeout(1);

const instance = form["pattern-autotoc"];
const spy_validation_marker = jest.spyOn(instance, "validation_marker");

inp1.dispatchEvent(events.change_event());
inp2.dispatchEvent(events.change_event());
await utils.timeout(10);
expect(spy_validation_marker).toHaveBeenCalledTimes(1);
jest.restoreAllMocks();
});
});