diff --git a/blocks/all.php b/blocks/all.php index 9f3aa9e..98b4093 100644 --- a/blocks/all.php +++ b/blocks/all.php @@ -48,3 +48,4 @@ function ( array $categories ) { require_once plugin_dir_path( __FILE__ ) . 'mobile-menu/class-mobile-menu-block.php'; require_once plugin_dir_path( __FILE__ ) . 'post-listing/class-post-listing-block.php'; require_once plugin_dir_path( __FILE__ ) . 'search-and-filter/class-search-and-filter-block.php'; +require_once plugin_dir_path( __FILE__ ) . 'tabs/class-tabs-block.php'; diff --git a/blocks/tabs/assets/_tabs.scss b/blocks/tabs/assets/_tabs.scss new file mode 100644 index 0000000..565096d --- /dev/null +++ b/blocks/tabs/assets/_tabs.scss @@ -0,0 +1,47 @@ +@use '/scss/globals'; +@use '/scss/extenders/container'; + +@mixin render { + .tabs__outer-wrapper { + @extend %container__outer-wrapper; + } + + .tabs__wrapper { + @extend %container__wrapper; + } + + .tabs__wrapper--reduce-bottom-space { + @extend %container__wrapper--reduce-bottom-space; + } + + .tabs__inner { + @extend %container__inner; + } + + .tabs__tabs { + display: flex; + } + + .tabs__tab-outer-wrapper, + .wp-block-acf-tabs-tab { + background-color: lightgray; + } + + .tabs__tab { + padding: globals.$space-top globals.$space-right 0 globals.$space-left; + background-color: transparent; + border: none; + cursor: pointer; + } + + .tabs__tab--active { + background-color: gray; + } + + .tabs__pattern { + &[hidden], + &[aria-hidden="true"] { + display: none; + } + } +} \ No newline at end of file diff --git a/blocks/tabs/assets/admin.js b/blocks/tabs/assets/admin.js new file mode 100644 index 0000000..1c69188 --- /dev/null +++ b/blocks/tabs/assets/admin.js @@ -0,0 +1,69 @@ +class Tabs extends AdminBlock { + + setup() { + this.setActiveTab(parseInt(this.getAttribute('active_tab'))); + this.setEventListeners(); + } + + setEventListeners() { + this.blockDiv.on( + 'click', + '.tabs__tab', + (event) => { + const index = this.blockDiv.find('.tabs__tab').index(event.currentTarget); + + this.capturePatterns(); + this.setActiveTab(index); + } + ); + + this.addListener( + (path, originalValue, newValue) => { + // Check if path matches pattern: innerBlocks[n].attributes.data.field_tabs_block_pattern + const regex = /^innerBlocks\[\d+\]\.attributes\.data\.field_tabs_block_pattern$/; + if (!regex.test(path)) { + return; + } + + this.capturePatterns(); + } + ); + } + + setActiveTab(index) { + this.blockDiv.find('.tabs__tab').removeClass('tabs__tab--active'); + this.blockDiv.find('.tabs__tab').eq(index).addClass('tabs__tab--active'); + this.setAttribute('active_tab', index); + } + + capturePatterns() { + this.loadBlock(); + + let patterns = []; + + for (const i in this.block.innerBlocks) { + patterns[i] = ''; + if (typeof this.block.innerBlocks[i].attributes == 'undefined') { + continue; + } + if (typeof this.block.innerBlocks[i].attributes.data == 'undefined') { + continue; + } + if (typeof this.block.innerBlocks[i].attributes.data.field_tabs_block_pattern !== 'undefined') { + patterns[i] = this.block.innerBlocks[i].attributes.data.field_tabs_block_pattern; + } + if (typeof this.block.innerBlocks[i].attributes.data.pattern !== 'undefined') { + patterns[i] = this.block.innerBlocks[i].attributes.data.pattern; + } + } + + this.setAttribute('patterns', patterns.join(',')); + } +} + +new AdminBlockInitializer( + '.wp-block-acf-tabs', + (blockDiv) => { + window.mytest = new Tabs(blockDiv); + } +); diff --git a/blocks/tabs/assets/tabs.js b/blocks/tabs/assets/tabs.js new file mode 100644 index 0000000..5342ad3 --- /dev/null +++ b/blocks/tabs/assets/tabs.js @@ -0,0 +1,57 @@ +class Tabs { + + elements = {}; + + constructor(wrapper) { + this.loadElements(wrapper); + this.setTabActiveState(); + this.addEventListeners(); + } + + loadElements(wrapper) { + this.elements.wrapper = wrapper; + this.elements.tab = wrapper.find('.tabs__tab'); + this.elements.pattern = wrapper.next().find('.tabs__pattern'); + } + + setTabActiveState() { + this.elements.tab.removeClass('tabs__tab--active'); + this.elements.pattern.prop('hidden', true); + this.elements.pattern.attr('aria-hidden', true); + + const index = this.elements.wrapper.data('active-tab'); + const tab = this.elements.tab.eq(index); + const pattern = this.elements.pattern.eq(index); + + tab.addClass('tabs__tab--active'); + pattern.prop('hidden', false); + pattern.attr('aria-hidden', false); + } + + addEventListeners() { + this.elements.tab.on( + 'click', + () => { + const index = this.elements.tab.index(event.currentTarget); + this.setActiveTab(index); + this.setTabActiveState(); + } + ); + } + + setActiveTab(index) { + this.elements.wrapper.data('active-tab', index); + } +} + +jQuery(document).ready( + () => { + const wrappers = jQuery('.tabs__wrapper'); + + wrappers.each( + (index) => { + new Tabs(wrappers.eq(index)); + } + ); + } +); diff --git a/blocks/tabs/class-tabs-block.php b/blocks/tabs/class-tabs-block.php new file mode 100644 index 0000000..9d9812a --- /dev/null +++ b/blocks/tabs/class-tabs-block.php @@ -0,0 +1,142 @@ + array( + 'type' => 'number', + 'default' => 0, + ), + 'patterns' => array( + 'type' => 'string', + 'default' => array(), + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function child_blocks(): array { + return array( + new Child_Block( + 'tab', + 'Tab', + array( + array( + 'key' => 'field_tabs_block_pattern', + 'name' => 'pattern', + 'label' => 'Pattern', + 'type' => 'select', + 'choices' => $this->get_block_pattern_choices(), + ), + ), + __DIR__ . '/templates/tab.php', + array(), + '', + array( + 'mode' => false, + 'color' => array( + 'text' => false, + 'background' => true, + ), + ) + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function template(): string { + return __DIR__ . '/templates/block.php'; + } + + /** + * {@inheritdoc} + */ + protected function use_default_wrapper_template(): bool { + return false; + } + + /** + * {@inheritdoc} + */ + protected function supports(): array { + return array( + 'mode' => false, + 'color' => array( + 'text' => false, + 'background' => true, + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function admin_scripts(): array { + return array( + new Script( + 'tabs-block', + plugin_dir_url( __FILE__ ) . 'assets/admin.js', + array( 'jquery', 'admin-block-initializer', 'admin-block' ), + '1.0.0' + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function scripts(): array { + return array( + new Script( + 'tabs-block', + plugin_dir_url( __FILE__ ) . 'assets/tabs.js', + array( 'jquery' ), + '1.0.0' + ), + ); + } +} diff --git a/blocks/tabs/templates/block.php b/blocks/tabs/templates/block.php new file mode 100644 index 0000000..105d055 --- /dev/null +++ b/blocks/tabs/templates/block.php @@ -0,0 +1,107 @@ +should_hide() ) { + return; +} + +/** + * Retrieve the active tab attribute. + * + * @var int The active tab index. + */ +$active_tab = Helpers::get_block_attribute( 'active_tab', $wp_block ); +$active_tab = $active_tab ?? 0; + +/** + * Retrieve the patterns attribute. + * + * @var array The patterns array. + */ +$patterns = Helpers::get_block_attribute( 'patterns', $wp_block ); +$patterns = $patterns ? explode( ',', $patterns ) : array(); + +/** + * Assign the inner block template to a variable. + * + * @var array The inner block template. + */ +$inner_block_template = array( + array( + $block['name'] . '-tab', + ), + array( + $block['name'] . '-tab', + ), + array( + $block['name'] . '-tab', + ), +); +?> + + + + + +
$block_name . '__outer-wrapper' ) ); ?>> + + +
+
+
+ +
+
+
+ +
+ $pattern ) : ?> +
+ hidden + aria-hidden="true" + + > + render_block_pattern( $pattern ); ?> +
+ +
+ + +
+ + + diff --git a/blocks/tabs/templates/tab.php b/blocks/tabs/templates/tab.php new file mode 100644 index 0000000..50d6aa3 --- /dev/null +++ b/blocks/tabs/templates/tab.php @@ -0,0 +1,39 @@ + 3, + 'content' => 'Tab', + ), + ), +); +?> + + + +
'tabs__tab-outer-wrapper' ) ); ?>> + + +
+ +
+ + +
+ diff --git a/includes/scripts/admin/admin-block.js b/includes/scripts/admin/admin-block.js index 9a062d7..9af6143 100644 --- a/includes/scripts/admin/admin-block.js +++ b/includes/scripts/admin/admin-block.js @@ -6,6 +6,12 @@ */ class AdminBlock { + /** + * The listeners to trigger when changes are detected. + * @type {Array} + */ + listeners = []; + /** * Constructor. * @@ -26,6 +32,7 @@ class AdminBlock { this.blockDiv = blockDiv; this.loadBlock(); + this.watchBlock(); this.setup(); } @@ -62,6 +69,45 @@ class AdminBlock { this.block = wp.data.select('core/block-editor').getBlock(clientId); } + /** + * Watch the block for changes and trigger listeners when changes are detected. + * + * @returns {void} + */ + watchBlock() { + const clientId = this.blockDiv.data('block'); + + wp.data.subscribe( + () => { + const updatedBlock = wp.data.select('core/block-editor').getBlock(clientId); + + AdminHelpers.deepCompare( + this.block, + updatedBlock, + (path, originalValue, newValue) => { + for (const listener of this.listeners) { + listener(path, originalValue, newValue); + } + this.loadBlock(); + } + ); + } + ); + } + + /** + * Add a listener to the block. The listener will be triggered when changes are detected. + * + * @param {Function} listener - The listener to add. The listener will be triggered with the following arguments: + * - {string} path - The path of the attribute that changed. + * - {any} originalValue - The original value of the attribute. + * - {any} newValue - The new value of the attribute. + * @returns {void} + */ + addListener(listener) { + this.listeners.push(listener); + } + /** * Overidable method to perform block specific setup tasks. */ diff --git a/includes/scripts/admin/admin-helpers.js b/includes/scripts/admin/admin-helpers.js new file mode 100644 index 0000000..2d8ea8b --- /dev/null +++ b/includes/scripts/admin/admin-helpers.js @@ -0,0 +1,113 @@ +/** + * Admin helper functions. + * + * @package Creode Blocks + */ +const AdminHelpers = { + /** + * Performs a deep compare on two objects and executes a callback function + * if any object property doesn't match. + * + * @param {Object} originalObj - The original object to compare. + * @param {Object} newObj - The new object to compare against. + * @param {Function} callback - Callback function executed when differences are found. + * Receives (path, originalValue, newValue) as arguments. + * If original value is primitive, newValue can be object/array. + * @param {string} pathPrefix - Internal parameter for building the property path. + * @returns {void} + */ + deepCompare(originalObj, newObj, callback, pathPrefix = '') { + // Get all unique keys from both objects + const allKeys = new Set([ + ...Object.keys(originalObj || {}), + ...Object.keys(newObj || {}) + ]); + + // Handle arrays + if (Array.isArray(originalObj) || Array.isArray(newObj)) { + const originalArray = Array.isArray(originalObj) ? originalObj : []; + const newArray = Array.isArray(newObj) ? newObj : []; + const maxLength = Math.max(originalArray.length, newArray.length); + + for (let i = 0; i < maxLength; i++) { + const arrayPath = pathPrefix ? `${pathPrefix}[${i}]` : `[${i}]`; + const originalValue = originalArray[i]; + const newValue = newArray[i]; + + // If index doesn't exist in one array + if (i >= originalArray.length) { + callback(arrayPath, undefined, newValue); + } else if (i >= newArray.length) { + callback(arrayPath, originalValue, undefined); + } + // If both are objects/arrays, recurse + else if (this.isObjectOrArray(originalValue) && this.isObjectOrArray(newValue)) { + this.deepCompare(originalValue, newValue, callback, arrayPath); + } + // If original is primitive and new is object/array, or vice versa, or both primitives differ + else if (originalValue !== newValue) { + callback(arrayPath, originalValue, newValue); + } + } + return; + } + + // Handle objects + allKeys.forEach(key => { + const currentPath = pathPrefix ? `${pathPrefix}.${key}` : key; + const originalValue = originalObj?.[key]; + const newValue = newObj?.[key]; + + // Check if key exists in both objects + const originalHasKey = originalObj && originalObj.hasOwnProperty(key); + const newHasKey = newObj && newObj.hasOwnProperty(key); + + // If key doesn't exist in one of the objects, treat as difference + if (!originalHasKey && newHasKey) { + callback(currentPath, undefined, newValue); + return; + } + + if (originalHasKey && !newHasKey) { + callback(currentPath, originalValue, undefined); + return; + } + + // If both are objects or arrays, recurse + if (this.isObjectOrArray(originalValue) && this.isObjectOrArray(newValue)) { + this.deepCompare(originalValue, newValue, callback, currentPath); + } + // If original is primitive and new is object/array, or vice versa, or both primitives differ + else if (originalValue !== newValue) { + callback(currentPath, originalValue, newValue); + } + }); + }, + + /** + * Check if a value is a primitive (string, number, boolean, null, undefined). + * + * @param {any} value - The value to check. + * @returns {boolean} - True if the value is a primitive. + */ + isPrimitive(value) { + return value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean'; + }, + + /** + * Check if a value is an object or array. + * + * @param {any} value - The value to check. + * @returns {boolean} - True if the value is an object or array. + */ + isObjectOrArray(value) { + return value !== null && + value !== undefined && + typeof value === 'object'; + } +}; + diff --git a/includes/scripts/admin/register-admin-scripts.php b/includes/scripts/admin/register-admin-scripts.php index 031b1c9..968a020 100644 --- a/includes/scripts/admin/register-admin-scripts.php +++ b/includes/scripts/admin/register-admin-scripts.php @@ -9,10 +9,17 @@ add_action( 'admin_enqueue_scripts', function () { + wp_register_script( + 'admin-helpers', + plugin_dir_url( __FILE__ ) . 'admin-helpers.js', + array( 'jquery' ), + '1.0.0', + true + ); wp_register_script( 'admin-block', plugin_dir_url( __FILE__ ) . 'admin-block.js', - array( 'jquery' ), + array( 'jquery', 'admin-helpers' ), '1.0.0', true );