Skip to content

Commit 595a210

Browse files
authored
feat: Add side menu variation - refs #272384
1 parent a992f20 commit 595a210

File tree

9 files changed

+449
-2
lines changed

9 files changed

+449
-2
lines changed

cypress/e2e/01-toc-basics.cy.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,54 @@ describe('Block Tests: Toc', () => {
194194
cy.contains('Volto Toc');
195195
cy.get('.table-of-contents .dropdown').contains('More').click();
196196
});
197+
198+
it('Add Block: add side menu TOC', () => {
199+
// Change page title
200+
cy.clearSlateTitle();
201+
cy.getSlateTitle().type('Volto Toc');
202+
cy.getSlate().click();
203+
204+
// Add TOC block
205+
cy.get('.ui.basic.icon.button.block-add-button').first().click();
206+
cy.get(".blocks-chooser .ui.form .field.searchbox input[type='text']").type(
207+
'table of contents',
208+
);
209+
cy.get('.button.toc').click();
210+
211+
cy.get('#sidebar-properties .form .react-select-container').first().click();
212+
cy.contains('Side Menu').click();
213+
214+
// Add headings
215+
cy.get('.ui.drag.block.inner.slate').click().type('Title 1').click();
216+
cy.get('.ui.drag.block.inner.slate span span span').setSelection('Title 1');
217+
cy.get('.slate-inline-toolbar .button-wrapper a[title="Title"]').click({
218+
force: true,
219+
});
220+
cy.get('.ui.drag.block.inner.slate').click().type('{enter}');
221+
222+
cy.get('.ui.drag.block.inner.slate').eq(1).click().type('Title 2').click();
223+
cy.get('.ui.drag.block.inner.slate span span span')
224+
.eq(1)
225+
.setSelection('Title 2');
226+
cy.get('.slate-inline-toolbar .button-wrapper a[title="Title"]').click({
227+
force: true,
228+
});
229+
cy.get('.ui.drag.block.inner.slate').eq(1).click().type('{enter}');
230+
231+
// Save page
232+
cy.get('#toolbar-save').click();
233+
cy.url().should('eq', Cypress.config().baseUrl + '/cypress/my-page');
234+
235+
// Check if the page contains the TOC and the headings
236+
cy.contains('Volto Toc');
237+
cy.contains('Title 1');
238+
cy.contains('Title 2');
239+
cy.get('a[href="#title-1"]').click();
240+
cy.get('a[href="#title-2"]').click();
241+
cy.get('h2[id="title-1"]').contains('Title 1');
242+
cy.get('h2[id="title-2"]').contains('Title 2');
243+
cy.get('.eea-side-menu').get('summary').click();
244+
cy.get('.eea-side-menu');
245+
cy.get('.eea-side-menu details').should('not.have.attr', 'open');
246+
});
197247
});

src/Block/TocView.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getBlocksFieldname,
1515
getBlocksLayoutFieldname,
1616
} from '@plone/volto/helpers';
17+
import withDeviceSize from '@eeacms/volto-block-toc/hocs/withDeviceSize';
1718

1819
export const getBlocksTocEntries = (properties, tocData) => {
1920
const blocksFieldName = getBlocksFieldname(properties);
@@ -132,7 +133,7 @@ const View = (props) => {
132133
const Renderer = variation?.view;
133134

134135
return (
135-
<div className={cx('table-of-contents', variation?.id)}>
136+
<div className={cx('table-of-contents', variation?.id, props.device)}>
136137
{props.mode === 'edit' && !data.title && !tocEntries.length && (
137138
<Message>Table of content</Message>
138139
)}
@@ -155,4 +156,4 @@ View.propTypes = {
155156
properties: PropTypes.objectOf(PropTypes.any).isRequired,
156157
};
157158

158-
export default injectIntl(withBlockExtensions(View));
159+
export default injectIntl(withBlockExtensions(withDeviceSize(View)));

src/Block/variations/SideMenu.jsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import Slugger from 'github-slugger';
4+
import AnchorLink from 'react-anchor-link-smooth-scroll';
5+
import { Icon } from '@plone/volto/components';
6+
7+
import downIcon from '@plone/volto/icons/down-key.svg';
8+
import upIcon from '@plone/volto/icons/up-key.svg';
9+
10+
import withEEASideMenu from '@eeacms/volto-block-toc/hocs/withEEASideMenu';
11+
import { normalizeString } from './helpers';
12+
import './less/side-menu.less';
13+
14+
const RenderMenuItems = ({ items }) => (
15+
<>
16+
{items.map((item, index) => {
17+
const { title, override_toc, plaintext, items: subItems } = item;
18+
const slug = override_toc
19+
? Slugger.slug(normalizeString(plaintext))
20+
: Slugger.slug(normalizeString(title));
21+
return (
22+
<React.Fragment key={index}>
23+
<li className="toc-menu-list-item">
24+
<AnchorLink href={`#${slug}`} className="toc-menu-list-title">
25+
{title}
26+
</AnchorLink>
27+
</li>
28+
{subItems && subItems.length > 0 && (
29+
<RenderMenuItems items={subItems} />
30+
)}
31+
</React.Fragment>
32+
);
33+
})}
34+
</>
35+
);
36+
37+
const RenderTocEntries = ({
38+
tocEntries,
39+
title,
40+
defaultOpen,
41+
isMenuOpenOnOutsideClick,
42+
}) => {
43+
const [isNavOpen, setIsNavOpen] = React.useState(!defaultOpen);
44+
45+
React.useEffect(() => {
46+
if (isMenuOpenOnOutsideClick === false) setIsNavOpen(false);
47+
}, [isMenuOpenOnOutsideClick]);
48+
49+
return (
50+
<details open={isNavOpen}>
51+
{/* eslint-disable-next-line */}
52+
<summary
53+
onClick={(e) => {
54+
e.preventDefault();
55+
setIsNavOpen(!isNavOpen);
56+
}}
57+
onKeyDown={(e) => {
58+
if (e.keyCode === 13 || e.keyCode === 32) {
59+
e.preventDefault();
60+
setIsNavOpen(!isNavOpen);
61+
}
62+
}}
63+
className="context-navigation-header accordion-header"
64+
>
65+
<span className="menuTitle">{title || ''}</span>
66+
<Icon name={isNavOpen ? upIcon : downIcon} size="40px" />
67+
</summary>
68+
<nav className="toc-menu">
69+
<ol className="toc-menu-list">
70+
<RenderMenuItems items={tocEntries} />
71+
</ol>
72+
</nav>
73+
</details>
74+
);
75+
};
76+
77+
const View = (props) => {
78+
const { data, tocEntries, device, isMenuOpenOnOutsideClick } = props;
79+
return (
80+
<RenderTocEntries
81+
defaultOpen={device === 'mobile' || device === 'tablet'}
82+
tocEntries={tocEntries}
83+
isMenuOpenOnOutsideClick={isMenuOpenOnOutsideClick}
84+
title={data?.title}
85+
/>
86+
);
87+
};
88+
89+
View.propTypes = {
90+
data: PropTypes.object.isRequired,
91+
tocEntries: PropTypes.array,
92+
mode: PropTypes.string,
93+
};
94+
95+
export default withEEASideMenu(View);

src/Block/variations/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import AccordionMenu from './AccordionMenu';
22
import DefaultTocRenderer from './DefaultTocRenderer';
33
import HorizontalMenu from './HorizontalMenu';
4+
import SideMenu from './SideMenu';
45

56
const ToCVariations = [
67
{
@@ -19,6 +20,11 @@ const ToCVariations = [
1920
title: 'Accordion Menu',
2021
view: AccordionMenu,
2122
},
23+
{
24+
id: 'eea-side-menu',
25+
title: 'Side Menu',
26+
view: SideMenu,
27+
},
2228
];
2329

2430
export default ToCVariations;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
@import (multiple, reference, optional) '../../../../theme.config';
2+
3+
@secondaryColor: #007b6c;
4+
@darkTextColor: #3d5265;
5+
6+
.eea-side-menu {
7+
.icon {
8+
font-size: 1.4rem !important;
9+
}
10+
11+
.content {
12+
--bg-color: transparent;
13+
padding: 0 !important;
14+
}
15+
16+
.accordion.ui.fluid {
17+
padding-top: 0;
18+
margin-top: 0;
19+
background-color: white;
20+
}
21+
22+
.menuTitle {
23+
font-size: 1.2rem;
24+
font-weight: bold;
25+
}
26+
27+
.toc-menu {
28+
background-color: white;
29+
}
30+
31+
.toc-menu-list {
32+
padding-left: 0;
33+
margin: 0;
34+
list-style: none;
35+
}
36+
37+
.toc-menu-list-title {
38+
display: block;
39+
padding: 0.75rem 1rem;
40+
border-bottom: 2px solid @darkTextColor;
41+
color: @darkTextColor;
42+
font-weight: bold;
43+
}
44+
45+
.toc-menu-list-item:hover {
46+
.toc-menu-list-title {
47+
color: @secondaryColor;
48+
font-weight: bold;
49+
}
50+
51+
.menuTitle {
52+
color: @darkTextColor;
53+
}
54+
}
55+
}

src/hocs/less/side-nav.less

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
@media screen and (min-width: 992px) {
2+
#main > #page-header:empty + .breadcrumbs {
3+
display: none;
4+
}
5+
6+
#page-header:empty + .breadcrumbs + .content-area {
7+
padding-top: 0 !important;
8+
margin-top: 0 !important;
9+
}
10+
11+
#page-document .eea-side-menu {
12+
display: none;
13+
}
14+
15+
#page-document:has(.banner > .image) .eea-side-menu + * {
16+
margin-top: 300px;
17+
}
18+
19+
#page-document .eea.banner {
20+
position: absolute;
21+
left: 0;
22+
}
23+
24+
#page-document .eea.banner + .documentDescription {
25+
margin-top: 200px;
26+
}
27+
28+
.has-side-nav {
29+
#view {
30+
display: grid;
31+
max-width: 1300px;
32+
margin: 0 auto;
33+
gap: 2rem;
34+
grid-template-areas: 'content nav';
35+
grid-template-columns: 4fr 1fr;
36+
grid-template-rows: auto 1fr;
37+
38+
#page-document {
39+
overflow: auto;
40+
width: auto !important;
41+
max-width: 100% !important;
42+
grid-area: content;
43+
}
44+
45+
> .eea-side-menu {
46+
position: sticky;
47+
top: 0;
48+
right: 1.5rem;
49+
min-width: 300px;
50+
height: fit-content;
51+
margin-bottom: 30px;
52+
grid-area: nav;
53+
}
54+
}
55+
}
56+
}
57+
58+
.eea-side-menu {
59+
grid-area: nav;
60+
61+
&:not(.mobile, .tablet) {
62+
max-width: 300px;
63+
}
64+
65+
&.mobile.fixed,
66+
&.tablet.fixed {
67+
position: fixed;
68+
z-index: 1;
69+
top: 0;
70+
width: 100%;
71+
background-color: #ffffff;
72+
}
73+
}
74+
75+
#page-edit,
76+
#page-add {
77+
.eea-side-menu {
78+
position: unset;
79+
right: unset;
80+
width: 100% !important;
81+
}
82+
}
83+
84+
@media screen and (min-width: 768px) and (max-width: 991px) {
85+
.has-side-nav #view {
86+
grid-template-areas:
87+
'nav'
88+
'content';
89+
}
90+
91+
.eea-side-menu {
92+
max-width: 100%;
93+
margin: 0 auto;
94+
}
95+
}

src/hocs/withDeviceSize.jsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React from 'react';
2+
3+
export default function withDeviceSize(WrappedComponent) {
4+
return (props) => {
5+
const [device, setDevice] = React.useState(null);
6+
7+
const updateScreenSize = () => {
8+
if (__CLIENT__) {
9+
const screenWidth =
10+
document.documentElement.clientWidth ||
11+
document.body.clientWidth ||
12+
window.innerWidth ||
13+
0;
14+
15+
setDevice(getDeviceConfig(screenWidth));
16+
}
17+
};
18+
19+
const getDeviceConfig = (width) => {
20+
// semantic ui breakpoints
21+
if (width < 768) {
22+
return 'mobile';
23+
} else if (width >= 768 && width < 992) {
24+
return 'tablet';
25+
} else if (width >= 992 && width < 1200) {
26+
return 'computer';
27+
} else if (width >= 1200 && width < 1920) {
28+
return 'large';
29+
} else if (width >= 1920) {
30+
return 'widescreen';
31+
}
32+
};
33+
34+
React.useEffect(() => {
35+
updateScreenSize();
36+
window.addEventListener('resize', updateScreenSize);
37+
return () => {
38+
window.removeEventListener('resize', updateScreenSize);
39+
};
40+
/* eslint-disable-next-line */
41+
}, []);
42+
43+
return <WrappedComponent {...props} device={device} />;
44+
};
45+
}

0 commit comments

Comments
 (0)