From 0885a140d01c881f4bcdf19cf6f19a62f0096f38 Mon Sep 17 00:00:00 2001 From: Osong Agberndifor <38656549+Osong-Michael@users.noreply.github.com> Date: Wed, 17 Jan 2024 13:15:57 +0100 Subject: [PATCH] PLANET-7382 Move Submenu block into master theme (#2195) - Moved the Submenu block to master theme with associated funcctions --- assets/src/blocks/Submenu/SubmenuBlock.js | 71 ++++++++++ assets/src/blocks/Submenu/SubmenuEditor.js | 126 ++++++++++++++++++ assets/src/blocks/Submenu/SubmenuFrontend.js | 19 +++ assets/src/blocks/Submenu/SubmenuItems.js | 30 +++++ assets/src/blocks/Submenu/SubmenuLevel.js | 61 +++++++++ assets/src/blocks/Submenu/example.js | 118 ++++++++++++++++ assets/src/blocks/Submenu/generateAnchor.js | 12 ++ .../blocks/Submenu/getHeadingsFromBlocks.js | 84 ++++++++++++ .../src/blocks/Submenu/getHeadingsFromDom.js | 33 +++++ assets/src/blocks/Submenu/getSubmenuStyle.js | 17 +++ assets/src/blocks/Submenu/makeHierarchical.js | 33 +++++ assets/src/blocks/editorIndex.js | 4 + assets/src/blocks/frontendIndex.js | 7 +- assets/src/functions/deepClone.js | 61 +++++++++ assets/src/functions/unescape.js | 6 + assets/src/scss/blocks.scss | 1 + .../blocks/Submenu/SubmenuEditorStyle.scss | 34 +++++ .../src/scss/blocks/Submenu/SubmenuStyle.scss | 119 +++++++++++++++++ assets/src/scss/editorStyle.scss | 1 + src/Blocks/Submenu.php | 107 +++++++++++++++ src/Loader.php | 1 + 21 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 assets/src/blocks/Submenu/SubmenuBlock.js create mode 100644 assets/src/blocks/Submenu/SubmenuEditor.js create mode 100644 assets/src/blocks/Submenu/SubmenuFrontend.js create mode 100644 assets/src/blocks/Submenu/SubmenuItems.js create mode 100644 assets/src/blocks/Submenu/SubmenuLevel.js create mode 100644 assets/src/blocks/Submenu/example.js create mode 100644 assets/src/blocks/Submenu/generateAnchor.js create mode 100644 assets/src/blocks/Submenu/getHeadingsFromBlocks.js create mode 100644 assets/src/blocks/Submenu/getHeadingsFromDom.js create mode 100644 assets/src/blocks/Submenu/getSubmenuStyle.js create mode 100644 assets/src/blocks/Submenu/makeHierarchical.js create mode 100644 assets/src/functions/deepClone.js create mode 100644 assets/src/functions/unescape.js create mode 100644 assets/src/scss/blocks/Submenu/SubmenuEditorStyle.scss create mode 100644 assets/src/scss/blocks/Submenu/SubmenuStyle.scss create mode 100644 src/Blocks/Submenu.php diff --git a/assets/src/blocks/Submenu/SubmenuBlock.js b/assets/src/blocks/Submenu/SubmenuBlock.js new file mode 100644 index 0000000000..89de6b4898 --- /dev/null +++ b/assets/src/blocks/Submenu/SubmenuBlock.js @@ -0,0 +1,71 @@ +import {SubmenuEditor} from './SubmenuEditor.js'; +import {example} from './example'; +import {getStyleLabel} from '../../functions/getStyleLabel'; + +const {__} = wp.i18n; + +const BLOCK_NAME = 'planet4-blocks/submenu'; + +export const registerSubmenuBlock = () => { + const {registerBlockType} = wp.blocks; + + registerBlockType(BLOCK_NAME, { + title: 'Submenu', + icon: 'welcome-widgets-menus', + category: 'planet4-blocks', + attributes: { + title: { + type: 'string', + default: '', + }, + submenu_style: { // Needed for old blocks conversion + type: 'integer', + default: 0, + }, + levels: { + type: 'array', + default: [{heading: 2, link: false, style: 'none'}], + }, + isExample: { + type: 'boolean', + default: false, + }, + exampleMenuItems: { // Used for the block's preview, which can't extract items from anything. + type: 'array', + }, + }, + supports: { + multiple: false, // Use the block just once per post. + html: false, + }, + styles: [ + { + name: 'long', + label: getStyleLabel( + __('Long full-width', 'planet4-blocks-backend'), + __('Use: on long pages (more than 5 screens) when list items are long (+ 10 words). No max items recommended.', 'planet4-blocks-backend') + ), + isDefault: true, + }, + { + name: 'short', + label: getStyleLabel( + __('Short full-width', 'planet4-blocks-backend'), + __('Use: on long pages (more than 5 screens) when list items are short (up to 5 words). No max items recommended.', 'planet4-blocks-backend') + ), + }, + { + name: 'sidebar', + label: getStyleLabel( + __('Short sidebar', 'planet4-blocks-backend'), + __('Use: on long pages (more than 5 screens) when list items are short (up to 10 words). Max items recommended: 9', 'planet4-blocks-backend') + ), + }, + ], + edit: SubmenuEditor, + save() { + return null; + }, + example, + }); +}; diff --git a/assets/src/blocks/Submenu/SubmenuEditor.js b/assets/src/blocks/Submenu/SubmenuEditor.js new file mode 100644 index 0000000000..76175a74b8 --- /dev/null +++ b/assets/src/blocks/Submenu/SubmenuEditor.js @@ -0,0 +1,126 @@ +import {Button, PanelBody} from '@wordpress/components'; +import {SubmenuLevel} from './SubmenuLevel'; +import {SubmenuItems} from './SubmenuItems'; +import {InspectorControls, RichText} from '@wordpress/block-editor'; +import {getSubmenuStyle} from './getSubmenuStyle'; +import {makeHierarchical} from './makeHierarchical'; +import {getHeadingsFromBlocks} from './getHeadingsFromBlocks'; +import {useSelect} from '@wordpress/data'; +import {deepClone} from '../../functions/deepClone'; + +const {__} = wp.i18n; + +const renderEdit = (attributes, setAttributes) => { + function addLevel() { + const [previousLastLevel] = attributes.levels.slice(-1); + const newLevel = previousLastLevel.heading + 1; + setAttributes({levels: attributes.levels.concat({heading: newLevel, link: false, style: 'none'})}); + } + + function onHeadingChange(index, value) { + const levels = deepClone(attributes.levels); + levels[index].heading = Number(value); + setAttributes({levels}); + } + + function onLinkChange(index, value) { + const levels = deepClone(attributes.levels); + levels[index].link = value; + setAttributes({levels}); + } + + function onStyleChange(index, value) { + const levels = deepClone(attributes.levels); + levels[index].style = value; // Possible values: "none", "bullet", "number" + setAttributes({levels}); + } + + function removeLevel() { + setAttributes({levels: attributes.levels.slice(0, -1)}); + } + + function getMinLevel(attr, index) { + if (index === 0) { + return null; + } + + return attr.levels[index - 1].heading; + } + + return ( + + + {attributes.levels.map((level, i) => ( + + ))} + + + + + ); +}; + +const renderView = (attributes, setAttributes, className) => { + const { + title, + levels, + submenu_style, + isExample, + exampleMenuItems, + } = attributes; + + const blocks = useSelect(select => select('core/block-editor').getBlocks(), null); + + const flatHeadings = getHeadingsFromBlocks(blocks, levels); + + const menuItems = isExample ? exampleMenuItems : makeHierarchical(flatHeadings); + + const style = getSubmenuStyle(className, submenu_style); + + return ( +
+ setAttributes({title: titl})} + withoutInteractiveFormatting + allowedFormats={[]} + /> + {menuItems.length > 0 ? + : +
+ {__('The submenu block produces no output on the editor.', 'planet4-blocks-backend')} +
+ } +
+ ); +}; + +export const SubmenuEditor = ({attributes, setAttributes, isSelected, className}) => ( + <> + {isSelected && renderEdit(attributes, setAttributes)} + {renderView(attributes, setAttributes, className)} + +); diff --git a/assets/src/blocks/Submenu/SubmenuFrontend.js b/assets/src/blocks/Submenu/SubmenuFrontend.js new file mode 100644 index 0000000000..2e7683bd19 --- /dev/null +++ b/assets/src/blocks/Submenu/SubmenuFrontend.js @@ -0,0 +1,19 @@ +import {getSubmenuStyle} from './getSubmenuStyle'; +import {SubmenuItems} from './SubmenuItems'; +import {makeHierarchical} from './makeHierarchical'; +import {getHeadingsFromDom} from './getHeadingsFromDom'; + +export const SubmenuFrontend = ({title, className, levels, submenu_style}) => { + const headings = getHeadingsFromDom(levels); + const menuItems = makeHierarchical(headings); + const style = getSubmenuStyle(className, submenu_style); + + return ( +
+ {!!title && ( +

{ title }

+ )} + +
+ ); +}; diff --git a/assets/src/blocks/Submenu/SubmenuItems.js b/assets/src/blocks/Submenu/SubmenuItems.js new file mode 100644 index 0000000000..0b443ac684 --- /dev/null +++ b/assets/src/blocks/Submenu/SubmenuItems.js @@ -0,0 +1,30 @@ +export const SubmenuItems = ({menuItems}) => { + const renderMenuItems = items => { + return items.map(({anchor, text, style, shouldLink, children}) => ( +
  • + {shouldLink ? + + {text} + : + {text} + } + {children && children.length > 0 && + + } +
  • + )); + }; + + return menuItems.length > 0 && ( +
    + +
    + ); +}; diff --git a/assets/src/blocks/Submenu/SubmenuLevel.js b/assets/src/blocks/Submenu/SubmenuLevel.js new file mode 100644 index 0000000000..109219c005 --- /dev/null +++ b/assets/src/blocks/Submenu/SubmenuLevel.js @@ -0,0 +1,61 @@ +import { + CheckboxControl, + SelectControl, +} from '@wordpress/components'; + +const {__} = wp.i18n; + +const getHeadingOptions = minLevel => { + return [ + {label: __('Heading 2', 'planet4-blocks-backend'), value: 2}, + {label: __('Heading 3', 'planet4-blocks-backend'), value: 3}, + {label: __('Heading 4', 'planet4-blocks-backend'), value: 4}, + {label: __('Heading 5', 'planet4-blocks-backend'), value: 5}, + {label: __('Heading 6', 'planet4-blocks-backend'), value: 6}, + ].map(option => ({...option, disabled: option.value <= minLevel})); +}; + +export const SubmenuLevel = props => { + const { + index, + heading, + onLinkChange, + link, + onHeadingChange, + style, + onStyleChange, + minLevel, + } = props; + + return ( +
    +

    {`${__('Level', 'planet4-blocks-backend')} ${Number(index + 1)}`}

    + onHeadingChange(index, e)} + /> + + onLinkChange(index, e)} + className="submenu-level-link" + /> + + onStyleChange(index, e)} + /> +
    +
    + ); +}; diff --git a/assets/src/blocks/Submenu/example.js b/assets/src/blocks/Submenu/example.js new file mode 100644 index 0000000000..7c8ba7d253 --- /dev/null +++ b/assets/src/blocks/Submenu/example.js @@ -0,0 +1,118 @@ +export const example = { + viewportWidth: 992, + attributes: { + isExample: true, + title: 'The block title', + exampleMenuItems: [ + { + text: 'Title 1', + anchor: 'Title 1', + style: 'number', + children: [], + level: 2, + shouldLink: true, + }, + { + text: 'Title 2', + anchor: 'Title 2', + style: 'number', + level: 2, + shouldLink: true, + children: [ + { + text: 'Title 2.1', + anchor: 'Title 2.1', + style: 'number', + level: 3, + shouldLink: true, + children: [ + { + text: 'Title 2.1.1', + anchor: 'Title 2.1.1', + style: 'bullet', + children: [], + level: 4, + shouldLink: false, + }, + ], + }, + { + text: 'Title 2.2', + anchor: 'Title 2.2', + style: 'number', + level: 3, + shouldLink: true, + children: [ + { + text: 'Title 2.2.1', + anchor: 'Title 2.2.1', + style: 'bullet', + level: 4, + shouldLink: false, + children: [], + }, + { + text: 'Title 2.2.2', + anchor: 'Title 2.2.2', + style: 'bullet', + level: 4, + shouldLink: false, + children: [], + }, + ], + }, + { + text: 'Title 2.3', + anchor: 'Title 2.3', + style: 'number', + level: 3, + shouldLink: true, + children: [], + }, + { + text: 'Title 2.4', + anchor: 'Title 2.4', + style: 'number', + level: 3, + shouldLink: true, + children: [ + { + text: 'Title 2.4.1', + anchor: 'Title 2.4.1', + style: 'bullet', + level: 4, + shouldLink: false, + children: [], + }, + ], + }, + { + text: 'Title 2.5', + anchor: 'Title 2.5', + style: 'number', + shouldLink: true, + children: [], + level: 3, + }, + ], + }, + { + text: 'Title 3', + anchor: 'Title 3', + style: 'number', + level: 2, + shouldLink: true, + children: [ + { + text: 'Title 3.1', + anchor: 'Title 3.1', + style: 'number', + level: 3, + shouldLink: true, + children: [], + }, + ], + }, + ], + }, +}; diff --git a/assets/src/blocks/Submenu/generateAnchor.js b/assets/src/blocks/Submenu/generateAnchor.js new file mode 100644 index 0000000000..f1641b8971 --- /dev/null +++ b/assets/src/blocks/Submenu/generateAnchor.js @@ -0,0 +1,12 @@ +export const generateAnchor = (text, previousAnchors) => { + const anchor = text.toLowerCase().trim().replace(/[^a-zA-Z\d:\u00C0-\u00FF]/g, '-'); + + let i = 0, + unique = anchor; + + while (previousAnchors.includes(unique)) { + unique = `${anchor}-${++i}`; + } + + return unique; +}; diff --git a/assets/src/blocks/Submenu/getHeadingsFromBlocks.js b/assets/src/blocks/Submenu/getHeadingsFromBlocks.js new file mode 100644 index 0000000000..71eba53769 --- /dev/null +++ b/assets/src/blocks/Submenu/getHeadingsFromBlocks.js @@ -0,0 +1,84 @@ +import {generateAnchor} from './generateAnchor'; +import {unescape} from '../../functions/unescape'; + +// We can put the other blocks that can have a heading inside in here along with the attribute containing the heading text. +// Then we can also filter those to include them in the menu. +const blockTypesWithHeadings = [ + {name: 'planet4-blocks/articles', fieldName: 'article_heading', level: 2}, +]; + +// Naive regex to remove html tags. Don't use anywhere else as it's too limited, but for the expected HTML in heading +// blocks this should be sufficient. +const stripTags = str => str.replace(/(<([^>]+)>)/ig, ''); //NOSONAR + +export const getHeadingsFromBlocks = (blocks, selectedLevels) => { + const headings = []; + blocks.forEach(block => { + if (block.name === 'core/heading') { + const blockLevel = block.attributes.level; + + const levelConfig = selectedLevels.find(selected => selected.heading === blockLevel); + + if (!levelConfig) { + return; + } + + const anchor = block.attributes.anchor || generateAnchor(block.attributes.content, headings.map(h => h.anchor)); + + headings.push({ + level: blockLevel, + // The content of RichText elements will always come out escaped. This is problematic as those will be displayed + // literally when we render them. It seems safe to unescape here as the value will not be used without escaping. + content: unescape(stripTags(block.attributes.content)), + anchor, + style: levelConfig.style, + shouldLink: levelConfig.link, + }); + + return; + } + + if (block.name === 'core/freeform') { + const parser = new DOMParser(); + const selector = selectedLevels.map(({heading}) => `h${heading}`).join(); + const doc = parser.parseFromString(block.attributes.content, 'text/html'); + + const classicHeadings = doc.querySelectorAll(selector); + + headings.push(...[...classicHeadings].map(h => { + const blockLevel = parseInt(h.tagName.replace('H', '')); + const levelConfig = selectedLevels.find(selected => selected.heading === blockLevel); + + const anchor = h.id || generateAnchor(h.innerText, headings.map(hh => hh.anchor)); + + return ({ + level: blockLevel, + content: h.innerText, + anchor, + style: levelConfig.style, + shouldLink: levelConfig.link, + }); + })); + + return; + } + + const blockType = blockTypesWithHeadings.find(({name}) => name === block.name); + + if (blockType) { + const {fieldName, level} = blockType; + const levelConfig = selectedLevels.find(selected => selected.heading === level); + + if (!levelConfig) { + return; + } + headings.push({ + level, + content: block.attributes[fieldName], + }); + } + }); + + return headings; +}; + diff --git a/assets/src/blocks/Submenu/getHeadingsFromDom.js b/assets/src/blocks/Submenu/getHeadingsFromDom.js new file mode 100644 index 0000000000..906bd12f2f --- /dev/null +++ b/assets/src/blocks/Submenu/getHeadingsFromDom.js @@ -0,0 +1,33 @@ +import {generateAnchor} from './generateAnchor'; + +const getHeadingLevel = heading => Number(heading.tagName.replace('H', '')); + +export const getHeadingsFromDom = selectedLevels => { + const container = document.querySelector('.page-content'); + if (!container || !selectedLevels) { + return []; + } + + // Get all heading tags that we need to query + const headingsSelector = selectedLevels.map(level => `:not(.submenu-block) h${level.heading}`); + + const usedAnchors = []; + + return [...container.querySelectorAll(headingsSelector)].map(heading => { + const levelConfig = selectedLevels.find(selected => selected.heading === getHeadingLevel(heading)); + + if (!heading.id) { + heading.id = generateAnchor(heading.textContent, usedAnchors); + } + + usedAnchors.push(heading.id); + + return ({ + content: heading.textContent, + level: levelConfig.heading, + style: levelConfig.style, + shouldLink: levelConfig.link, + anchor: heading.id, + }); + }); +}; diff --git a/assets/src/blocks/Submenu/getSubmenuStyle.js b/assets/src/blocks/Submenu/getSubmenuStyle.js new file mode 100644 index 0000000000..78a6634a75 --- /dev/null +++ b/assets/src/blocks/Submenu/getSubmenuStyle.js @@ -0,0 +1,17 @@ +import {getStyleFromClassName} from '../../functions/getStyleFromClassName'; + +// Map for old attribute 'submenu_style' +const SUBMENU_STYLES = { + 1: 'long', + 2: 'short', + 3: 'sidebar', +}; + +export const getSubmenuStyle = (className, submenu_style) => { + const styleClass = getStyleFromClassName(className); + if (styleClass) { + return styleClass; + } + + return submenu_style ? SUBMENU_STYLES[submenu_style] : 'long'; +}; diff --git a/assets/src/blocks/Submenu/makeHierarchical.js b/assets/src/blocks/Submenu/makeHierarchical.js new file mode 100644 index 0000000000..621ef40fae --- /dev/null +++ b/assets/src/blocks/Submenu/makeHierarchical.js @@ -0,0 +1,33 @@ +export const makeHierarchical = headings => { + let previousMenuItem; + + return headings.reduce((menuItems, heading) => { + const {level, shouldLink, anchor, content, style} = heading; + + // const parent = deeperThanPrevious ? previousHeading.children : menuItems; + let possibleParent = previousMenuItem || menuItems; + + while (possibleParent.level && possibleParent.level >= level) { + possibleParent = possibleParent.parent; + } + + const parent = possibleParent; + + const container = parent === menuItems ? menuItems : parent.children; + + const menuItem = { + text: content, + style, + children: [], + parent, + level, + shouldLink, + anchor, + }; + container.push(menuItem); + + previousMenuItem = menuItem; + + return menuItems; + }, []); +}; diff --git a/assets/src/blocks/editorIndex.js b/assets/src/blocks/editorIndex.js index 2e845099da..7c78e77c2c 100644 --- a/assets/src/blocks/editorIndex.js +++ b/assets/src/blocks/editorIndex.js @@ -1,10 +1,14 @@ import {registerPostsListBlock} from './PostsList'; import {registerActionsList} from './ActionsList'; +import {registerSubmenuBlock} from './Submenu/SubmenuBlock'; wp.domReady(() => { // Make sure to unregister the posts-list native variation before registering planet4-blocks/posts-list-block wp.blocks.unregisterBlockVariation('core/query', 'posts-list'); + // Blocks + registerSubmenuBlock(); + // Beta blocks registerActionsList(); registerPostsListBlock(); diff --git a/assets/src/blocks/frontendIndex.js b/assets/src/blocks/frontendIndex.js index 96a79e4151..a1cb6f9c0e 100644 --- a/assets/src/blocks/frontendIndex.js +++ b/assets/src/blocks/frontendIndex.js @@ -1,5 +1,9 @@ +import {createRoot} from 'react-dom/client'; +import {SubmenuFrontend} from './Submenu/SubmenuFrontend'; + // Render React components const COMPONENTS = { + 'planet4-blocks/submenu': SubmenuFrontend, }; document.addEventListener('DOMContentLoaded', () => { @@ -15,7 +19,8 @@ document.addEventListener('DOMContentLoaded', () => { return; } const attributes = JSON.parse(blockNode.dataset.attributes); - wp.element.render(, blockNode); + const rootElement = createRoot(blockNode); + rootElement.render(); } ); }); diff --git a/assets/src/functions/deepClone.js b/assets/src/functions/deepClone.js new file mode 100644 index 0000000000..7ef7cf7fd9 --- /dev/null +++ b/assets/src/functions/deepClone.js @@ -0,0 +1,61 @@ +// Following is needed to get the right global object in all environments. +// See https://stackoverflow.com/a/6930376/4961158. +let global; +try { + global = Function('return this')(); +} catch (e) { + global = window; +} + +// A deep clone that has the same result as doing JSON.parse(JSON.stringify(value)). +// It's 2 to 5 times faster than both JSON switcheroo and lodash's cloneDeep in most cases. +// Only for really large object sizes, e.g. 500KB and over it's roughly the same or a little slower than Lodash. +// It does not preserve the object in the same way as Lodash does, i.e. non-built in classes are not preserved, just +// like they aren't in the JSON switcheroo approach. So it's only intended to be used where you would otherwise be able +// to use the JSON approach. +const _deepClone = (value, ancestors = [], clones = []) => { + const type = typeof value; + if (type === 'function') { + return undefined; + } + if (type !== 'object') { + return value; + } + if (ancestors.includes(value)) { + return clones[ancestors.indexOf(value)]; + } + if (Array.isArray(value)) { + const cloned = value.map(v => _deepClone(v, ancestors, clones)); + ancestors.push(value); + clones.push(cloned); + // We actually do want to check the builtins here. + /* eslint-disable no-prototype-builtins */ + if (value.hasOwnProperty('index')) { + cloned.index = value.index; + } + if (value.hasOwnProperty('input')) { + cloned.input = value.input; + } + /* eslint-enable no-prototype-builtins */ + + return cloned; + } + + const valueOf = value.valueOf(); + + // If there is a valueOf and it's not an object, we need to pass it to the new constructor to get a new object with + // the same value. Needed for Date, also makes Boolean objects work (even though you shouldn't use them). + const param = typeof valueOf === 'object' ? null : valueOf; + // Don't try to construct custom objects, use Object instead, which behaves the same as the JSON approach. + const constructor = global[value.constructor.name] || Object; + const newObject = new constructor(param); + + ancestors.push(value); + clones.push(newObject); + + Object.keys(value).forEach(k => newObject[k] = _deepClone(value[k], ancestors, clones)); + + return newObject; +}; + +export const deepClone = value => _deepClone(value); diff --git a/assets/src/functions/unescape.js b/assets/src/functions/unescape.js new file mode 100644 index 0000000000..04eb000b8b --- /dev/null +++ b/assets/src/functions/unescape.js @@ -0,0 +1,6 @@ +export const unescape = input => { + const e = document.createElement('textarea'); + e.innerHTML = input; + // handle case of empty input + return e.childNodes.length === 0 ? '' : e.childNodes[0].nodeValue; +}; diff --git a/assets/src/scss/blocks.scss b/assets/src/scss/blocks.scss index 0e41e1c882..ae678444f0 100644 --- a/assets/src/scss/blocks.scss +++ b/assets/src/scss/blocks.scss @@ -5,3 +5,4 @@ @import "blocks/Cookies/CookiesStyle"; @import "blocks/Counter/CounterStyle"; @import "blocks/Spreadsheet"; +@import "blocks/Submenu/SubmenuStyle"; diff --git a/assets/src/scss/blocks/Submenu/SubmenuEditorStyle.scss b/assets/src/scss/blocks/Submenu/SubmenuEditorStyle.scss new file mode 100644 index 0000000000..e71473145a --- /dev/null +++ b/assets/src/scss/blocks/Submenu/SubmenuEditorStyle.scss @@ -0,0 +1,34 @@ +.submenu-level-link label { + margin-bottom: 0; +} + +[data-type="planet4-blocks/submenu"] { + clear: both; +} + +.editor-styles-wrapper { + .submenu-block h2 { + padding-left: 16px; + color: var(--grey-800); + font-family: var(--font-family-heading); + } + + // Needed to make it easily selectable in the editor + .submenu-sidebar { + float: none; + margin-right: 0; + margin-left: auto; + + html[dir="rtl"] & { + float: none; + margin-right: auto; + margin-left: 0; + } + } +} + +.EmptyMessage { + font-family: var(--font-family-primary); + padding: $sp-4; + background: var(--gp-green-200); +} diff --git a/assets/src/scss/blocks/Submenu/SubmenuStyle.scss b/assets/src/scss/blocks/Submenu/SubmenuStyle.scss new file mode 100644 index 0000000000..425981cb29 --- /dev/null +++ b/assets/src/scss/blocks/Submenu/SubmenuStyle.scss @@ -0,0 +1,119 @@ +// On outer element as otherwise it will not be clickable when floating on the right. +div[data-render="planet4-blocks/submenu"] { + z-index: 4; +} + +.submenu-block { + _-- { + border-radius: 4px; + box-shadow: 0 3px 8px 0 rgba(28, 28, 28, 0.2); + background-color: var(--white); + padding: $sp-5 $sp-6 $sp-6 $sp-6; + } + + h1, + h2 { + --submenu-block-heading-- { + font-family: var(--font-family-primary); + color: var(--grey-900); + padding-inline-start: 0; + } + } + + .submenu-menu { + display: flex; + flex-wrap: wrap; + + ul.submenu-item { + flex-basis: 100%; + + @include large-and-up { + flex-basis: 50%; + } + } + + a { + color: var(--color-text-body); + } + + ul { + --submenu-block-menu-- { + font-family: var(--font-family-tertiary); + color: var(--color-text-body); + margin: 0; + padding: 0; + } + list-style: none; + } + + li { + &.list-style-bullet { + --submenu-block-bullet-item-- { + margin-inline-end: 0; + margin-inline-start: $sp-4; + } + list-style: disc; + } + + &.list-style-number { + --submenu-block-number-item-- { + margin-inline-start: $sp-4; + } + list-style: decimal; + } + } + } + + &.submenu-short { + .submenu-menu { + @include medium-and-up { + ul.submenu-item { + flex-basis: 100%; + column-count: 3; + + li:before { + content: ""; + } + } + } + } + } + + &.submenu-long { + .submenu-menu { + ul.submenu-item { + flex-basis: 100%; + column-count: 1; + + @include medium-and-up { + column-count: 2; + } + } + } + } + + &.submenu-sidebar { + z-index: 4; + + @include medium-and-up { + float: right; + max-width: 350px; + margin-bottom: $sp-2; + margin-inline-start: $sp-2; + + html[dir="rtl"] & { + float: left; + } + } + + .submenu-menu { + ul.submenu-item { + flex-basis: 100%; + + @include large-and-up { + flex-basis: 100%; + } + } + } + } +} diff --git a/assets/src/scss/editorStyle.scss b/assets/src/scss/editorStyle.scss index 2f3e948e7e..3265d5e3c7 100644 --- a/assets/src/scss/editorStyle.scss +++ b/assets/src/scss/editorStyle.scss @@ -34,3 +34,4 @@ @import "blocks/CarouselHeader/CarouselHeaderEditorStyle"; @import "blocks/Accordion/AccordionEditorStyle"; @import "blocks/Cookies/CookiesEditorStyle"; +@import "blocks/Submenu/SubmenuEditorStyle.scss"; diff --git a/src/Blocks/Submenu.php b/src/Blocks/Submenu.php new file mode 100644 index 0000000000..420151db87 --- /dev/null +++ b/src/Blocks/Submenu.php @@ -0,0 +1,107 @@ +register_submenu_block(); + } + + /** + * Register Submenu block. + */ + public function register_submenu_block(): void + { + register_block_type( + self::get_full_block_name(), + [ + // todo: Remove when all content is migrated. + 'render_callback' => [ self::class, 'render_frontend' ], + 'attributes' => [ + 'title' => [ + 'type' => 'string', + 'default' => '', + ], + 'submenu_style' => [ // Needed for old blocks conversion. + 'type' => 'integer', + 'default' => 0, + ], + /** + * Levels is an array of objects. + * Object structure: + * { + * heading: 'integer' + * link: 'boolean' + * style: 'string' + * } + */ + 'levels' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + // In JSON Schema you can specify object properties in the properties attribute. + 'properties' => [ + 'heading' => [ + 'type' => 'integer', + ], + 'link' => [ + 'type' => 'boolean', + ], + 'style' => [ + 'type' => 'string', + ], + ], + ], + 'default' => [ + [ + 'heading' => 2, + 'link' => false, + 'style' => 'none', + ], + ], + ], + ], + ] + ); + + add_action('enqueue_block_editor_assets', [ self::class, 'enqueue_editor_assets' ]); + add_action('wp_enqueue_scripts', [ self::class, 'enqueue_frontend_assets' ]); + } + + /** + * Required by the `Base_Block` class. + * + * @param array $fields Unused, required by the abstract function. + * @phpcs:disable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter + */ + public function prepare_data(array $fields): array + { + return []; + } + // @phpcs:enable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter +} diff --git a/src/Loader.php b/src/Loader.php index 18cf3ca491..6e3bdfc4d4 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -162,6 +162,7 @@ public static function add_blocks(): void new Blocks\Gallery();//NOSONAR new Blocks\GuestBook();//NOSONAR new Blocks\Spreadsheet();//NOSONAR + new Blocks\Submenu();//NOSONAR if (!BetaBlocks::is_active()) { return;