diff --git a/addons/web/static/src/core/notebook/notebook.js b/addons/web/static/src/core/notebook/notebook.js
new file mode 100644
index 00000000000..e6f6bc59942
--- /dev/null
+++ b/addons/web/static/src/core/notebook/notebook.js
@@ -0,0 +1,160 @@
+/** @odoo-module **/
+
+import { scrollTo } from "@web/core/utils/scrolling";
+
+const { Component, onWillDestroy, onWillUpdateProps, useEffect, useRef, useState } = owl;
+
+/**
+ * A notebook component that will render only the current page and allow
+ * switching between its pages.
+ *
+ * You can also set pages using a template component. Use an array with
+ * the `pages` props to do such rendering.
+ *
+ * Pages can also specify their index in the notebook.
+ *
+ * e.g.:
+ * PageTemplate.template = xml`
+
+ `;
+
+ * `pages` could be:
+ * [
+ * {
+ * Component: PageTemplate,
+ * props: {
+ * title: "Some Title 1",
+ * isVisible: bool,
+ * text: "Text Content 1",
+ * },
+ * name: 'some_name' // optional
+ * },
+ * {
+ * Component: PageTemplate,
+ * props: {
+ * title: "Some Title 2",
+ * isVisible: bool,
+ * text: "Text Content 2",
+ * },
+ * },
+ * ]
+ *
+ *
+ *
+ * Page Content 1
+ *
+ *
+ * Page Content 2
+ *
+ *
+ *
+ * @extends Component
+ */
+
+export class Notebook extends Component {
+ setup() {
+ this.activePane = useRef("activePane");
+ this.anchorTarget = null;
+ this.pages = this.computePages(this.props);
+ this.state = useState({ currentPage: null });
+ this.state.currentPage = this.props.defaultPage || this.computeActivePage();
+ const onAnchorClicked = this.onAnchorClicked.bind(this);
+ this.env.bus.addEventListener("SCROLLER:ANCHOR_LINK_CLICKED", onAnchorClicked);
+ useEffect(
+ () => {
+ this.props.onPageUpdate(this.state.currentPage);
+ if (this.anchorTarget) {
+ const matchingEl = this.activePane.el.querySelector(`#${this.anchorTarget}`);
+ scrollTo(matchingEl, { isAnchor: true });
+ this.anchorTarget = null;
+ }
+ },
+ () => [this.state.currentPage]
+ );
+ onWillUpdateProps((nextProps) => {
+ this.pages = this.computePages(nextProps);
+ this.state.currentPage = this.computeActivePage();
+ });
+ onWillDestroy(() => {
+ this.env.bus.removeEventListener("SCROLLER:ANCHOR_LINK_CLICKED", onAnchorClicked);
+ });
+ }
+
+ get navItems() {
+ return this.pages.filter((e) => e[1].isVisible);
+ }
+
+ get page() {
+ const page = this.pages.find((e) => e[0] === this.state.currentPage)[1];
+ return page.Component && page;
+ }
+
+ onAnchorClicked(ev) {
+ if (!this.props.anchors) {
+ return;
+ }
+ const id = ev.detail.detail.id.substring(1);
+ if (this.props.anchors[id]) {
+ if (this.state.currentPage !== this.props.anchors[id].target) {
+ ev.preventDefault();
+ ev.detail.detail.originalEv.preventDefault();
+ this.anchorTarget = id;
+ this.state.currentPage = this.props.anchors[id].target;
+ }
+ }
+ }
+
+ computePages(props) {
+ if (!props.slots && !props.pages) {
+ return [];
+ }
+ const pages = [];
+ const pagesWithIndex = [];
+ for (const [k, v] of Object.entries({ ...props.slots, ...props.pages })) {
+ if (v.props) {
+ v.isVisible = v.props.isVisible;
+ v.title = v.props.title;
+ v.index = v.props.index;
+ }
+ const id = v.id || k;
+ if (v.index) {
+ pagesWithIndex.push([id, v]);
+ } else {
+ pages.push([id, v]);
+ }
+ }
+ for (const page of pagesWithIndex) {
+ pages.splice(page[1].index, 0, page);
+ }
+ return pages;
+ }
+
+ computeActivePage() {
+ if (!this.pages.length) {
+ return null;
+ }
+ const current = this.state.currentPage;
+ if (!current || (current && !this.pages.find((e) => e[0] === current)[1].isVisible)) {
+ const candidate = this.pages.find((v) => v[1].isVisible);
+ return candidate ? candidate[0] : null;
+ }
+ return current;
+ }
+}
+
+Notebook.template = "web.Notebook";
+Notebook.defaultProps = {
+ className: "",
+ orientation: "horizontal",
+ onPageUpdate: () => {},
+};
+Notebook.props = {
+ slots: { type: Object, optional: true },
+ pages: { type: Object, optional: true },
+ class: { optional: true },
+ className: { type: String, optional: true },
+ anchors: { type: Object, optional: true },
+ defaultPage: { type: String, optional: true },
+ orientation: { type: String, optional: true },
+ onPageUpdate: { type: Function, optional: true },
+};
diff --git a/addons/web/static/src/core/notebook/notebook.scss b/addons/web/static/src/core/notebook/notebook.scss
new file mode 100644
index 00000000000..77553ab1d36
--- /dev/null
+++ b/addons/web/static/src/core/notebook/notebook.scss
@@ -0,0 +1,18 @@
+.o_notebook {
+ &.horizontal {
+ flex-direction: column;
+
+ .nav.nav-tabs {
+ flex-flow: row nowrap;
+ }
+
+ }
+
+ &.vertical {
+ flex-direction: row;
+
+ .nav.nav-pills {
+ width: max-content;
+ }
+ }
+}
diff --git a/addons/web/static/src/core/notebook/notebook.xml b/addons/web/static/src/core/notebook/notebook.xml
new file mode 100644
index 00000000000..2604f6a501a
--- /dev/null
+++ b/addons/web/static/src/core/notebook/notebook.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/tests/core/notebook_tests.js b/addons/web/static/tests/core/notebook_tests.js
new file mode 100644
index 00000000000..53be41c4b4f
--- /dev/null
+++ b/addons/web/static/tests/core/notebook_tests.js
@@ -0,0 +1,296 @@
+/** @odoo-module **/
+
+import { Notebook } from "@web/core/notebook/notebook";
+import { makeTestEnv } from "@web/../tests/helpers/mock_env";
+import { click, getFixture, mount } from "@web/../tests/helpers/utils";
+
+const { Component, xml } = owl;
+let target;
+
+QUnit.module("Components", (hooks) => {
+ hooks.beforeEach(async () => {
+ target = getFixture();
+ });
+
+ QUnit.module("Notebook");
+
+ QUnit.test("not rendered if empty slots", async (assert) => {
+ const env = await makeTestEnv();
+ await mount(Notebook, target, { env, props: {} });
+ assert.containsNone(target, "div.o_notebook");
+ });
+
+ QUnit.test("notebook with multiple pages given as slots", async (assert) => {
+ const env = await makeTestEnv();
+
+ class Parent extends Component {}
+ Parent.template = xml`
+
+ About the bird
+ Owls are birds from the order Strigiformes which includes over
+ 200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...
+
+
+ Their favorite activity: hunting
+ Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.
+
+
+ TODO find a great secret about OWLs.
+
+ `;
+ Parent.components = { Notebook };
+
+ await mount(Parent, target, { env });
+ assert.containsOnce(target, "div.o_notebook");
+ assert.hasClass(
+ target.querySelector(".o_notebook"),
+ "horizontal",
+ "default orientation is set as horizontal"
+ );
+ assert.hasClass(
+ target.querySelector(".nav"),
+ "nav-tabs",
+ "navigation container uses the right class to display as horizontal tabs"
+ );
+ assert.containsN(
+ target,
+ ".o_notebook_headers a.nav-link",
+ 2,
+ "navigation link is present for each visible page"
+ );
+ assert.hasClass(
+ target.querySelector(".o_notebook_headers .nav-item:first-child a"),
+ "active",
+ "first page is selected by default"
+ );
+ assert.strictEqual(
+ target.querySelector(".tab-pane.active").firstElementChild.textContent,
+ "About the bird",
+ "first page content is displayed by the notebook"
+ );
+
+ await click(target, ".o_notebook_headers .nav-item:nth-child(2) a");
+ assert.hasClass(
+ target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"),
+ "active",
+ "second page is now selected"
+ );
+ assert.strictEqual(
+ target.querySelector(".tab-pane.active").firstElementChild.textContent,
+ "Their favorite activity: hunting",
+ "second page content is displayed by the notebook"
+ );
+ });
+
+ QUnit.test("notebook with defaultPage props", async (assert) => {
+ const env = await makeTestEnv();
+
+ class Parent extends Component {}
+ Parent.template = xml`
+
+ About the bird
+ Owls are birds from the order Strigiformes which includes over
+ 200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...
+
+
+ Their favorite activity: hunting
+ Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.
+
+
+ TODO find a great secret about OWLs.
+
+ `;
+ Parent.components = { Notebook };
+
+ await mount(Parent, target, { env });
+ assert.containsOnce(target, "div.o_notebook");
+ assert.hasClass(
+ target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"),
+ "active",
+ "second page is selected by default"
+ );
+ assert.strictEqual(
+ target.querySelector(".tab-pane.active").firstElementChild.textContent,
+ "Their favorite activity: hunting",
+ "second page content is displayed by the notebook"
+ );
+ });
+
+ QUnit.test("notebook with defaultPage set on invisible page", async (assert) => {
+ const env = await makeTestEnv();
+
+ class Parent extends Component {}
+ Parent.template = xml`
+
+ About the bird
+ Owls are birds from the order Strigiformes which includes over
+ 200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...
+
+
+ Their favorite activity: hunting
+ Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.
+
+
+ Oooops
+ TODO find a great secret to reveal about OWLs.
+
+ `;
+ Parent.components = { Notebook };
+
+ await mount(Parent, target, { env });
+ assert.containsOnce(target, "div.o_notebook");
+ assert.containsNone(
+ target,
+ ".o_notebook_headers .nav-item a.active",
+ "no tab is selected as the default page is not visible"
+ );
+ assert.containsN(
+ target,
+ ".o_notebook_headers a.nav-link",
+ 2,
+ "navigation link is only present for visible pages"
+ );
+ assert.strictEqual(
+ target.querySelector(".tab-pane.active").firstElementChild.textContent,
+ "Oooops",
+ "third page content is displayed by the notebook"
+ );
+ });
+
+ QUnit.test("notebook set vertically", async (assert) => {
+ const env = await makeTestEnv();
+
+ class Parent extends Component {}
+ Parent.template = xml`
+
+ About the bird
+ Owls are birds from the order Strigiformes which includes over
+ 200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...
+
+
+ Their favorite activity: hunting
+ Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.
+
+ `;
+ Parent.components = { Notebook };
+
+ await mount(Parent, target, { env });
+ assert.containsOnce(target, "div.o_notebook");
+ assert.hasClass(
+ target.querySelector(".o_notebook"),
+ "vertical",
+ "orientation is set as vertical"
+ );
+ assert.hasClass(
+ target.querySelector(".nav"),
+ "nav-pills",
+ "navigation container uses the right class to display as vertical buttons"
+ );
+ });
+
+ QUnit.test("notebook pages rendered by a template component", async (assert) => {
+ const env = await makeTestEnv();
+
+ class NotebookPageRenderer extends Component {}
+ NotebookPageRenderer.template = xml`
+
+
+ `;
+
+ class Parent extends Component {
+ setup() {
+ this.pages = [
+ {
+ Component: NotebookPageRenderer,
+ props: {
+ index: 1,
+ isVisible: true,
+ title: "Page 2",
+ text: "Second page rendered by a template component",
+ },
+ },
+ {
+ Component: NotebookPageRenderer,
+ props: {
+ index: 2,
+ isVisible: true,
+ title: "Page 3",
+ text: "Third page rendered by a template component",
+ },
+ id: "page_three", // required to be set as default page
+ },
+ ];
+ }
+ }
+ Parent.template = xml`
+
+ Page 1
+ First page set directly as a slot
+
+
+ Page 4
+
+ `;
+ Parent.components = { Notebook };
+
+ await mount(Parent, target, { env });
+
+ assert.containsOnce(target, "div.o_notebook");
+ assert.hasClass(
+ target.querySelector(".o_notebook_headers .nav-item:nth-child(3) a"),
+ "active",
+ "third page is selected by default"
+ );
+
+ await click(target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"));
+ assert.strictEqual(
+ target.querySelector(".o_notebook_content p").textContent,
+ "Second page rendered by a template component",
+ "displayed content corresponds to the current page"
+ );
+ });
+
+ QUnit.test("each page is different", async (assert) => {
+ const env = await makeTestEnv();
+
+ class Page extends Component {}
+ Page.template = xml`Coucou
`;
+
+ class Parent extends Component {
+ setup() {
+ this.pages = [
+ {
+ Component: Page,
+ props: {
+ index: 1,
+ isVisible: true,
+ title: "Page 1",
+ },
+ },
+ {
+ Component: Page,
+ props: {
+ index: 2,
+ isVisible: true,
+ title: "Page 2",
+ },
+ },
+ ];
+ }
+ }
+ Parent.template = xml``;
+ Parent.components = { Notebook };
+
+ await mount(Parent, target, { env });
+
+ const firstPage = target.querySelector("h3");
+ assert.ok(firstPage instanceof HTMLElement);
+
+ await click(target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"));
+
+ const secondPage = target.querySelector("h3");
+ assert.ok(firstPage instanceof HTMLElement);
+
+ assert.notEqual(firstPage, secondPage);
+ });
+});