Tabs

Tab 1 title
Tab 2 title
Tab 3 title
Tab 4 title
Tab 5 title

This is a default heading.

This is a default paragraph.

This is a default heading 2.

This is a default paragraph.

This is a default heading.

This is a default paragraph.

This is a default heading.

This is a default paragraph.

This is a default heading.

This is a default paragraph.

Tab 1 title
Tab 2 title
Tab 3 title
Tab 4 title
Tab 5 title
Tab 6 title

This is a default heading 1.

This is a default paragraph.

This is a default heading 2.

This is a default paragraph.

This is a default heading 3.

This is a default paragraph.

This is a default heading 4.

This is a default paragraph.

This is a default heading 5.

This is a default paragraph.

This is a default heading.

This is a default paragraph.

block.json

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wlc/tabs",
  "title": "Tabs",
  "category": "widgets",
  "icon": "index-card",
  "description": "A block to display tabs",
  "supports": {
    "html": false
  },
  "keywords": [
    "tabs"
  ],
  "attributes": {
    "tabs": {
      "type": "array",
      "default": [
        {
          "index": 0,
          "title": "Tab 1"
        },
        {
          "index": 1,
          "title": "Tab 2"
        }
      ]
    },
    "navFontSize": {
      "type": "integer",
      "default": 16
    },
    "navHorizontalPadding": {
      "type": "integer",
      "default": 25
    },
    "navVerticalPadding": {
      "type": "integer",
      "default": 15
    },
    "showActiveIndicator": {
      "type": "boolean",
      "default": true
    },
    "position": {
      "type": "string",
      "default": "horizontal"
    },
    "navBg": {
      "type": "string",
      "default": "#f1f1f1"
    },
    "navColor": {
      "type": "string",
      "default": "#000000"
    },
    "navBgActive": {
      "type": "string",
      "default": "#b1e9b4"
    },
    "navColorActive": {
      "type": "string",
      "default": "#ffffff"
    }
  },
  "textdomain": "WLC",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "viewScript": "file:view.js"
}

edit.js

import { __ } from "@wordpress/i18n";
import { useBlockProps, useInnerBlocksProps, InspectorControls, RichText } from "@wordpress/block-editor";
import { PanelBody, SelectControl, RangeControl, ToggleControl, Button, ColorPicker } from "@wordpress/components";
import { useState } from "react";

const { useDispatch, useSelect } = wp.data;
const { createBlock } = wp.blocks;

const ALLOWED_BLOCKS = ["wlc/tabs-item", "wlc/tabs"];
const TEMPLATE = [
  ["wlc/tabs-item", { tabIndex: 0 }],
  ["wlc/tabs-item", { tabIndex: 1 }],
];

const edit = ({ attributes, setAttributes, clientId }) => {
  const {
    tabs,
    position,
    navFontSize,
    navHorizontalPadding,
    navVerticalPadding,
    showActiveIndicator,
    navBg,
    navColor,
    navBgActive,
    navColorActive,
  } = attributes;

  const blockProps = useBlockProps({
    style: {
      "--nav-color": navColor,
      "--nav-bg": navBg,
      "--nav-color-active": navColorActive,
      "--nav-bg-active": navBgActive,
      "--nav-font-size": `${navFontSize}px`,
      "--nav-vertical-padding": `${navVerticalPadding}px`,
      "--nav-horizontal-padding": `${navHorizontalPadding}px`,
    },
    className: "wlc-tabs",
  });

  const { children } = useInnerBlocksProps(blockProps, {
    allowedBlocks: ALLOWED_BLOCKS,
    template: TEMPLATE,
  });

  const [activeTabIndex, setActiveTabIndex] = useState(0);

  const { replaceInnerBlocks } = useDispatch("core/block-editor");
  const { innerBlocks } = useSelect((select) => ({
    innerBlocks: select("core/block-editor").getBlocks(clientId),
  }));

  const handleAddTab = () => {
    const tabIndex = tabs.length;
    const newTab = {
      index: tabIndex,
      title: __(`Tab ${tabIndex + 1} title`, "WLC"),
    };

    setAttributes({ tabs: [...tabs, newTab] });

    const newBlock = createBlock("wlc/tabs-item", { tabIndex });
    replaceInnerBlocks(clientId, [...innerBlocks, newBlock], false);

    setActiveTabIndex(tabIndex);
  };

  const handleRemoveTab = (index) => {
    if (window.confirm(__("Are you sure you want to remove this tab?", "WLC"))) {
      const newTabs = tabs.filter((tab, i) => i !== index).map((tab, i) => ({ ...tab, index: i }));

      setAttributes({ tabs: newTabs });

      const newBlocks = innerBlocks.filter((block, i) => i !== index);
      replaceInnerBlocks(clientId, newBlocks, false);

      setActiveTabIndex(index === 0 ? 0 : index - 1);
    }
  };

  const setActiveTab = (index) => {
    setActiveTabIndex(index);
  };

  const handleTitleChange = (value, index) => {
    const newTabs = [...tabs];
    newTabs[index] = { ...newTabs[index], title: value };
    setAttributes({ tabs: newTabs });
  };

  return (
    <div {...blockProps}>
      <InspectorControls>
        <PanelBody title={__("Tabs Settings", "WLC")}>
          <SelectControl
            label={__("Position", "WLC")}
            value={position}
            options={[
              { value: "horizontal", label: __("Horizontal", "WLC") },
              { value: "vertical", label: __("Vertical", "WLC") },
            ]}
            onChange={(value) => setAttributes({ position: value })}
          />
          <RangeControl
            label={__("Nav Font Size (px)", "WLC")}
            value={navFontSize}
            onChange={(value) => setAttributes({ navFontSize: value })}
            min={10}
            max={30}
          />
          <RangeControl
            label={__("Nav Horizontal Padding (px)", "WLC")}
            value={navHorizontalPadding}
            onChange={(value) => setAttributes({ navHorizontalPadding: value })}
            min={0}
            max={50}
          />
          <RangeControl
            label={__("Nav Vertical Padding (px)", "WLC")}
            value={navVerticalPadding}
            onChange={(value) => setAttributes({ navVerticalPadding: value })}
            min={0}
            max={50}
          />
          <ToggleControl
            label={__("Show Active Indicator", "WLC")}
            checked={showActiveIndicator}
            onChange={(value) => setAttributes({ showActiveIndicator: value })}
          />
        </PanelBody>

        <PanelBody title={__("Colors", "WLC")} initialOpen={false}>
          <div className="components-base-control">
            <div className="components-base-control__field">
              <label className="components-base-control__label">{__("Nav Background Color", "WLC")}</label>
              <ColorPicker color={navBg} onChangeComplete={(value) => setAttributes({ navBg: value.hex })} />
            </div>
          </div>
          <div className="components-base-control">
            <div className="components-base-control__field">
              <label className="components-base-control__label">{__("Nav Background Active Color", "WLC")}</label>
              <ColorPicker
                color={navBgActive}
                onChangeComplete={(value) => setAttributes({ navBgActive: value.hex })}
              />
            </div>
          </div>
        </PanelBody>
      </InspectorControls>

      <div className={`wlc-tabs__inner is-${position}`}>
        <div className={`wlc-tabs__nav ${showActiveIndicator === true ? "tabs-indicator" : ""}`}>
          {tabs.map((tab, index) => (
            <div
              key={index}
              className={`tab-item${index === activeTabIndex ? " active" : ""}`}
              onClick={() => setActiveTab(index)}
            >
              <RichText
                tagName="span"
                value={tab.title}
                onChange={(value) => handleTitleChange(value, index)}
                placeholder={__("Enter tab title...", "WLC")}
              />
              <Button className="remove-btn" onClick={() => handleRemoveTab(index)}>
                &times;
              </Button>
            </div>
          ))}
          <Button className="add-item" onClick={handleAddTab}>
            {__("+", "WLC")}
          </Button>
        </div>
        <div className="wlc-tabs__content" data-active-tab={activeTabIndex}>
          {children}
        </div>
      </div>
    </div>
  );
};

export default edit;

editor.scss

index.js

/**
 * WordPress dependencies
 */
import { registerBlockType } from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import './editor.scss';
import './style.scss';
import './view.js';
import edit from './edit';
import save from './save';
import metadata from './block.json';

registerBlockType( metadata.name, {
  edit,
  save,
} );

save.js

import { useBlockProps, RichText, InnerBlocks } from "@wordpress/block-editor";

const save = ({ attributes }) => {
  const {
    tabs,
    position,
    navFontSize,
    navHorizontalPadding,
    navVerticalPadding,
    showActiveIndicator,
    navBg,
    navColor,
    navBgActive,
    navColorActive,
  } = attributes;

  const blockProps = useBlockProps.save({
    style: {
      "--nav-color": navColor,
      "--nav-bg": navBg,
      "--nav-color-active": navColorActive,
      "--nav-bg-active": navBgActive,
      "--nav-font-size": `${navFontSize}px`,
      "--nav-vertical-padding": `${navVerticalPadding}px`,
      "--nav-horizontal-padding": `${navHorizontalPadding}px`,
    },
    className: "wlc-tabs",
  });

  return (
    <div {...blockProps}>
      <div className={`wlc-tabs__inner is-${position}`}>
        <div className="wlc-tabs__nav">
          {tabs.map((tab, index) => (
            <div
              key={index}
              className={`tab-item${showActiveIndicator === true ? " tab-item--indicator" : ""}`}
              data-tab-index={index}
            >
              <RichText.Content tagName="span" value={tab.title} />
            </div>
          ))}
        </div>
        <div className="wlc-tabs__content">
          <InnerBlocks.Content />
        </div>
      </div>
    </div>
  );
};

export default save;

style.scss

.wlc-tabs {
  &__inner {
    display: flex;
    flex-direction: column;

    &.is-vertical {
      flex-direction: row;

      .wlc-tabs__nav {
        width: 25%;
        margin: 0;
        flex-wrap: wrap;
        flex-direction: column;

        &.tabs-indicator {
          .tab-item {
            &.active {
              &::after {
                top: calc(50% - 0.469rem);
                left: auto;
                border-width: 0.625rem 0 0.625rem 0.938rem;
                border-color: transparent transparent transparent var(--nav-bg-active);
                transform: rotate(0deg) translateX(100%);
              }
            }
          }
        }

        .add-item {
          width: 100%;
          margin: 0;
        }
      }

      .wlc-tabs__content {
        flex-grow: 1;
        padding-left: 2.5rem;
      }
    }
  }

  &__nav {
    margin-bottom: 1.5rem;
    overflow-x: auto;
    display: flex;

    @media screen and (min-width: 992px) {
      overflow-x: visible;
    }

    &.tabs-indicator {
      .tab-item {
        &.active {
          &::after {
            content: "";
            right: 0;
            left: 0;
            bottom: 0;
            width: 0;
            height: 0;
            border-style: solid;
            border-width: 0.938rem 0.625rem 0 0.625rem;
            border-color: var(--nav-bg-active) transparent transparent transparent;
            transform: rotate(0deg) translateY(100%);
            margin: 0 auto;
            position: absolute;
          }
        }
      }
    }

    .tab-item {
      cursor: pointer;
      width: 100%;
      color: red!important;
      font-size: var(--nav-font-size);
      line-height: 1.2;
      box-sizing: border-box;
      background: var(--nav-bg);
      padding: var(--nav-vertical-padding) var(--nav-horizontal-padding);
      display: flex;
      align-items: center;
      white-space: nowrap;
      position: relative;

      &.active {
        color: var(--nav-color-active);
        font-weight: bold;
        background: var(--nav-bg-active);
      }

      .remove-btn {
        outline: none;
        color: #fff;
        font-size: 1.125rem;
        line-height: 0.8;
        text-align: center;
        width: 0.938rem;
        height: 0.938rem;
        top: 0.5rem;
        right: 0.5rem;
        padding: 0;
        mix-blend-mode: difference;
        display: inline-block;
        position: absolute;
      }
    }

    .add-item {
      font-size: 1.563rem;
      line-height: 1;
      border: 0.063rem solid #dcdcdc;
      margin-left: 1.25rem;
      padding: 0 0.75rem 0.125rem;
      display: inline-block; 
      height: auto;
    }
  }

  &__content { 
    @for $i from 0 through 20 {
      &[data-active-tab="#{$i}"] {
      > .wp-block-wlc-tabs-item:nth-child(#{$i + 1}) {
          display: block;
        }
      }
    }

    .wp-block-wlc-tabs-item {
      display: none;

      &.active {
        display: block;
      }
    }
  }
}

view.js

document.addEventListener("DOMContentLoaded", () => {
  class WLCTabs {
    constructor(tabContainer) {
      this.tabContainer = tabContainer;
      this.activeClass = "active";

      this.navContainer = this.tabContainer.querySelector(".wlc-tabs__nav");
      this.contentContainer = this.tabContainer.querySelector(".wlc-tabs__content");

      if (!this.navContainer || !this.contentContainer) return;

      this.navItems = Array.from(this.navContainer.children).filter((child) => child.classList.contains("tab-item"));
      this.contentTabs = Array.from(this.contentContainer.children).filter((child) =>
        child.classList.contains("wp-block-wlc-tabs-item"),
      );

      if (this.navItems.length === 0 || this.contentTabs.length === 0) return;

      this.initTabs();
    }

    initTabs() {
      this.navItems.forEach((item, index) => {
        item.setAttribute("role", "tab");
        item.setAttribute("tabindex", index === 0 ? "0" : "-1");
        item.setAttribute("aria-selected", index === 0 ? "true" : "false");
        item.setAttribute("aria-controls", `tab-content-${index}`);
        item.id = `tab-${index}`;
      });

      this.contentTabs.forEach((tab, index) => {
        tab.setAttribute("role", "tabpanel");
        tab.setAttribute("aria-labelledby", `tab-${index}`);
        tab.id = `tab-content-${index}`;
        tab.setAttribute("tabindex", "0");
        tab.hidden = index !== 0;
      });

      this.setActiveTabByIndex(0);

      this.navContainer.addEventListener("click", (event) => {
        const clickedItem = event.target.closest(".tab-item");
        if (clickedItem) {
          const clickedIndex = this.navItems.indexOf(clickedItem);
          if (clickedIndex !== -1) {
            this.setActiveTabByIndex(clickedIndex);
            clickedItem.focus();
          }
        }
      });

      this.navContainer.addEventListener("keydown", (event) => {
        const currentIndex = this.navItems.findIndex((item) => item.getAttribute("tabindex") === "0");
        if (event.key === "ArrowRight") {
          const nextIndex = (currentIndex + 1) % this.navItems.length;
          this.setActiveTabByIndex(nextIndex);
          this.navItems[nextIndex].focus();
        } else if (event.key === "ArrowLeft") {
          const prevIndex = (currentIndex - 1 + this.navItems.length) % this.navItems.length;
          this.setActiveTabByIndex(prevIndex);
          this.navItems[prevIndex].focus();
        }
      });
    }

    setActiveTabByIndex(index) {
      this.navItems.forEach((item, i) => {
        item.classList.toggle(this.activeClass, i === index);
        item.setAttribute("aria-selected", i === index ? "true" : "false");
        item.setAttribute("tabindex", i === index ? "0" : "-1");
      });

      this.contentTabs.forEach((tab, i) => {
        tab.classList.toggle(this.activeClass, i === index);
        tab.hidden = i !== index;
      });
    }
  }

  const tabContainers = document.querySelectorAll(".wlc-tabs");
  tabContainers.forEach((tabContainer) => new WLCTabs(tabContainer));
});