' ),
+ 'text' => wp_kses( wp_unslash( $answer ), $allowed_html ),
];
$data['inLanguage'] = get_bloginfo( 'language' );
diff --git a/languages/blockparty-faq-fr_FR-dfbff627e6c248bcb3b61d7d06da9ca9.json b/languages/blockparty-faq-fr_FR-dfbff627e6c248bcb3b61d7d06da9ca9.json
new file mode 100644
index 0000000..0553b08
--- /dev/null
+++ b/languages/blockparty-faq-fr_FR-dfbff627e6c248bcb3b61d7d06da9ca9.json
@@ -0,0 +1 @@
+{"translation-revision-date":"2026-01-26 16:50+0100","generator":"WP-CLI\/2.11.0","source":"build\/index.js","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","lang":"fr_FR","plural-forms":"nplurals=2; plural=(n != 1);"},"Add FAQ item":["Ajouter un \u00e9l\u00e9ment"],"Remove FAQ item":["Supprimer l\u2019\u00e9l\u00e9ment"],"FAQ Settings":["R\u00e9glages de FAQ"],"Accordion behavior":["Comportement d\u2019accord\u00e9on"],"If enabled, the HTML structure will be interpreted as an accordion from screen readers.":["Si c\u2019est activ\u00e9, la structure HTML du bloc sera interpr\u00e9t\u00e9 en tant qu\u2019accord\u00e9on pour les technologies d\u2019assistance."],"Question\u2026?":["Question\u2026?"],"Answer\u2026":["R\u00e9ponse\u2026"]}}}
\ No newline at end of file
diff --git a/languages/blockparty-faq-fr_FR.mo b/languages/blockparty-faq-fr_FR.mo
new file mode 100644
index 0000000..239529e
Binary files /dev/null and b/languages/blockparty-faq-fr_FR.mo differ
diff --git a/languages/blockparty-faq-fr_FR.po b/languages/blockparty-faq-fr_FR.po
new file mode 100644
index 0000000..7d38da3
--- /dev/null
+++ b/languages/blockparty-faq-fr_FR.po
@@ -0,0 +1,117 @@
+# Copyright (C) 2026 Be API Technical team
+# This file is distributed under the GPL-2.0-or-later.
+msgid ""
+msgstr ""
+"Project-Id-Version: Blockparty Faq 1.0.2\n"
+"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/blockparty-faq\n"
+"POT-Creation-Date: 2026-01-26T15:49:49+00:00\n"
+"PO-Revision-Date: 2026-01-26 16:50+0100\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: fr_FR\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 3.8\n"
+"X-Domain: blockparty-faq\n"
+
+#. Plugin Name of the plugin
+#: blockparty-faq.php
+msgid "Blockparty FAQ"
+msgstr "Blockparty FAQ"
+
+#. Plugin URI of the plugin
+#. Author URI of the plugin
+#: blockparty-faq.php
+msgid "https://beapi.fr"
+msgstr "https://beapi.fr"
+
+#. Description of the plugin
+#: blockparty-faq.php
+msgid ""
+"A FAQ block for WordPress Editor that provided structured data based on FAQ "
+"schema."
+msgstr ""
+"Un bloc FAQ pour l’éditeur de WordPress qui ajouter des données structurées "
+"basées sur le schéma FAQ."
+
+#. Author of the plugin
+#: blockparty-faq.php
+msgid "Be API Technical team"
+msgstr "L’équipe technique de Be API"
+
+#: build/index.js:1
+msgid "Add FAQ item"
+msgstr "Ajouter un élément"
+
+#: build/index.js:1
+msgid "Remove FAQ item"
+msgstr "Supprimer l’élément"
+
+#: build/index.js:1
+msgid "FAQ Settings"
+msgstr "Réglages de FAQ"
+
+#: build/index.js:1
+msgid "Accordion behavior"
+msgstr "Comportement d’accordéon"
+
+#: build/index.js:1
+msgid ""
+"If enabled, the HTML structure will be interpreted as an accordion from "
+"screen readers."
+msgstr ""
+"Si c’est activé, la structure HTML du bloc sera interprété en tant "
+"qu’accordéon pour les technologies d’assistance."
+
+#: build/index.js:1
+msgid "Question…?"
+msgstr "Question…?"
+
+#: build/index.js:1
+msgid "Answer…"
+msgstr "Réponse…"
+
+#: block.json
+msgctxt "block title"
+msgid "FAQ"
+msgstr "FAQ"
+
+#: block.json
+msgctxt "block description"
+msgid ""
+"A FAQ block for WordPress Editor that provided structured data based on FAQ "
+"schema."
+msgstr ""
+"A FAQ block for WordPress Editor that provided structured data based on FAQ "
+"schema."
+
+#: build/faq-answer/block.json
+msgctxt "block title"
+msgid "FAQ Answer"
+msgstr "Réponse FAQ"
+
+#: build/faq-answer/block.json
+msgctxt "block description"
+msgid "Content of the FAQ answer."
+msgstr "Contenu de la réponse FAQ."
+
+#: build/faq-item/block.json
+msgctxt "block title"
+msgid "FAQ Item"
+msgstr "Élément de FAQ"
+
+#: build/faq-item/block.json
+msgctxt "block description"
+msgid "A FAQ item containing a question and an answer."
+msgstr "Un élément de FAQ contient une question et une réponse."
+
+#: build/faq-question/block.json
+msgctxt "block title"
+msgid "FAQ Question"
+msgstr "Question de FAQ"
+
+#: build/faq-question/block.json
+msgctxt "block description"
+msgid "Content of the FAQ question."
+msgstr "Contenu de la question FAQ."
diff --git a/languages/blockparty-faq.pot b/languages/blockparty-faq.pot
new file mode 100644
index 0000000..9d514cd
--- /dev/null
+++ b/languages/blockparty-faq.pot
@@ -0,0 +1,104 @@
+# Copyright (C) 2026 Be API Technical team
+# This file is distributed under the GPL-2.0-or-later.
+msgid ""
+msgstr ""
+"Project-Id-Version: Blockparty FAQ 1.0.2\n"
+"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/blockparty-faq\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"POT-Creation-Date: 2026-01-26T15:49:49+00:00\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"X-Generator: WP-CLI 2.11.0\n"
+"X-Domain: blockparty-faq\n"
+
+#. Plugin Name of the plugin
+#: blockparty-faq.php
+msgid "Blockparty FAQ"
+msgstr ""
+
+#. Plugin URI of the plugin
+#. Author URI of the plugin
+#: blockparty-faq.php
+msgid "https://beapi.fr"
+msgstr ""
+
+#. Description of the plugin
+#: blockparty-faq.php
+msgid "A FAQ block for WordPress Editor that provided structured data based on FAQ schema."
+msgstr ""
+
+#. Author of the plugin
+#: blockparty-faq.php
+msgid "Be API Technical team"
+msgstr ""
+
+#: build/index.js:1
+msgid "Add FAQ item"
+msgstr ""
+
+#: build/index.js:1
+msgid "Remove FAQ item"
+msgstr ""
+
+#: build/index.js:1
+msgid "FAQ Settings"
+msgstr ""
+
+#: build/index.js:1
+msgid "Accordion behavior"
+msgstr ""
+
+#: build/index.js:1
+msgid "If enabled, the HTML structure will be interpreted as an accordion from screen readers."
+msgstr ""
+
+#: build/index.js:1
+msgid "Question…?"
+msgstr ""
+
+#: build/index.js:1
+msgid "Answer…"
+msgstr ""
+
+#: block.json
+msgctxt "block title"
+msgid "FAQ"
+msgstr ""
+
+#: block.json
+msgctxt "block description"
+msgid "A FAQ block for WordPress Editor that provided structured data based on FAQ schema."
+msgstr ""
+
+#: build/faq-answer/block.json
+msgctxt "block title"
+msgid "FAQ Answer"
+msgstr ""
+
+#: build/faq-answer/block.json
+msgctxt "block description"
+msgid "Content of the FAQ answer."
+msgstr ""
+
+#: build/faq-item/block.json
+msgctxt "block title"
+msgid "FAQ Item"
+msgstr ""
+
+#: build/faq-item/block.json
+msgctxt "block description"
+msgid "A FAQ item containing a question and an answer."
+msgstr ""
+
+#: build/faq-question/block.json
+msgctxt "block title"
+msgid "FAQ Question"
+msgstr ""
+
+#: build/faq-question/block.json
+msgctxt "block description"
+msgid "Content of the FAQ question."
+msgstr ""
diff --git a/package-lock.json b/package-lock.json
index 372493b..b42f093 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.2",
"license": "GPL-2.0-or-later",
"dependencies": {
+ "@beapi/be-a11y": "^1.7.2",
"@wordpress/block-editor": "^12.22.0",
"@wordpress/blocks": "^12.31.0",
"@wordpress/components": "^27.2.0",
@@ -2032,6 +2033,15 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
+ "node_modules/@beapi/be-a11y": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@beapi/be-a11y/-/be-a11y-1.7.2.tgz",
+ "integrity": "sha512-4/k5Iul3wFUiQQDn5zGeCJB5MOx+rfkfJnP2EwyJpqFA2MdSw/OSnmIP1tb+8XheHASQxHqMl4iNUvaivAKIPQ==",
+ "dependencies": {
+ "body-scroll-lock": "4.0.0-beta.0",
+ "oneloop.js": "^5.1.4"
+ }
+ },
"node_modules/@csstools/selector-specificity": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz",
@@ -8484,6 +8494,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
+ "node_modules/body-scroll-lock": {
+ "version": "4.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/body-scroll-lock/-/body-scroll-lock-4.0.0-beta.0.tgz",
+ "integrity": "sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ=="
+ },
"node_modules/bonjour-service": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz",
@@ -18560,6 +18575,11 @@
"wrappy": "1"
}
},
+ "node_modules/oneloop.js": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/oneloop.js/-/oneloop.js-5.2.2.tgz",
+ "integrity": "sha512-hcZ9yMYdnr2ND6ZwXeUNcfiAKDRspxUPwU9gzVgotm0ZGxkY7Hnc6kKLObOmOMH8IREBHVP/LDKlF3/qj8nH5Q=="
+ },
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
diff --git a/package.json b/package.json
index 102e9be..3648855 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,13 @@
"start": "wp-scripts start",
"packages-update": "wp-scripts packages-update",
"start:env": "wp-env start --config=./wp-env.json",
- "stop:env": "wp-env stop --config=./wp-env.json"
+ "stop:env": "wp-env stop --config=./wp-env.json",
+ "setup:env": "wp-env run cli wp plugin install wordpress-seo --activate --allow-root",
+ "make-pot": "wp i18n make-pot . languages/blockparty-faq.pot --exclude=\"src\" --domain=blockparty-faq",
+ "make-json": "wp i18n make-json languages/blockparty-faq-fr_FR.po languages/ --no-purge"
},
"dependencies": {
+ "@beapi/be-a11y": "^1.7.2",
"@wordpress/block-editor": "^12.22.0",
"@wordpress/blocks": "^12.31.0",
"@wordpress/components": "^27.2.0",
diff --git a/src/FaqList.js b/src/FaqList.js
deleted file mode 100644
index 2987d7b..0000000
--- a/src/FaqList.js
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * External dependencies
- */
-import { filter } from 'lodash';
-
-/**
- * WordPress dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { Button } from '@wordpress/components';
-import { chevronUp, chevronDown, trash, plusCircle } from '@wordpress/icons';
-import { RichText } from '@wordpress/block-editor';
-
-export default function FaqList( { questions, onChange } ) {
- const generateId = ( prefix ) => {
- return `${ prefix }-${ new Date().getTime() }`;
- };
-
- const arrayMove = ( init, target ) => {
- questions = [ ...questions ];
- const question = questions[ init ];
- questions[ init ] = questions[ target ];
- questions[ target ] = question;
- onChange( questions );
- };
-
- const onAddElement = () => {
- questions = [
- ...questions,
- {
- id: generateId( 'faq' ),
- question: '',
- answer: '',
- },
- ];
- onChange( questions );
- };
- const onRemoveElement = ( removedIndex ) => {
- questions = filter( questions, ( value, index ) => {
- return index !== removedIndex;
- } );
- onChange( questions );
- };
-
- const onMoveUpElement = ( index ) => {
- arrayMove( index, index - 1 );
- };
-
- const onMoveDownElement = ( index ) => {
- arrayMove( index, index + 1 );
- };
-
- const updateQuestion = ( id, question ) => {
- onChange(
- questions.map( ( el ) =>
- el.id === id ? { ...el, question } : el
- )
- );
- };
-
- const updateAnswer = ( id, answer ) => {
- onChange(
- questions.map( ( el ) => ( el.id === id ? { ...el, answer } : el ) )
- );
- };
-
- return (
- <>
- { questions.map( ( item, index ) => (
-
- { index !== 0 && (
-
- ) ) }
- onAddElement() }
- />
- >
- );
-}
diff --git a/src/edit.js b/src/edit.js
deleted file mode 100644
index 736c6a0..0000000
--- a/src/edit.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { useBlockProps } from '@wordpress/block-editor';
-/**
- * Internal dependencies
- */
-import FaqList from './FaqList';
-
-export default function Edit( { attributes, setAttributes } ) {
- const { questions } = attributes;
- return (
-
-
-
- setAttributes( { questions: content } )
- }
- />
-
-
- );
-}
diff --git a/src/faq-answer/block.json b/src/faq-answer/block.json
new file mode 100644
index 0000000..b7a954e
--- /dev/null
+++ b/src/faq-answer/block.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "blockparty/faq-answer",
+ "version": "1.0.2",
+ "title": "FAQ Answer",
+ "category": "design",
+ "parent": [ "blockparty/faq-item" ],
+ "description": "Content of the FAQ answer.",
+ "supports": {
+ "html": false,
+ "inserter": false,
+ "reusable": false
+ },
+ "attributes": {
+ "isAccordion": {
+ "type": "boolean",
+ "default": true
+ }
+ },
+ "textdomain": "blockparty-faq"
+}
diff --git a/src/faq-answer/edit.js b/src/faq-answer/edit.js
new file mode 100644
index 0000000..363b551
--- /dev/null
+++ b/src/faq-answer/edit.js
@@ -0,0 +1,31 @@
+/**
+ * WordPress dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+import { InnerBlocks } from '@wordpress/block-editor';
+import { __ } from '@wordpress/i18n';
+
+const ALLOWED_BLOCKS = [ 'core/paragraph', 'core/heading', 'core/list' ];
+
+export default function Edit() {
+ const blockProps = useBlockProps( {
+ className: 'faq__panel',
+ } );
+
+ return (
+
+
+
+ );
+}
diff --git a/src/faq-answer/editor.scss b/src/faq-answer/editor.scss
new file mode 100644
index 0000000..289a442
--- /dev/null
+++ b/src/faq-answer/editor.scss
@@ -0,0 +1 @@
+// FAQ Answer editor styles
diff --git a/src/faq-answer/index.js b/src/faq-answer/index.js
new file mode 100644
index 0000000..4e9e122
--- /dev/null
+++ b/src/faq-answer/index.js
@@ -0,0 +1,27 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import './editor.scss';
+import Edit from './edit';
+import save from './save';
+import { postContent } from '@wordpress/icons';
+
+registerBlockType( 'blockparty/faq-answer', {
+ /**
+ * @see ./edit.js
+ */
+ edit: Edit,
+
+ /**
+ * @see ./save.js
+ */
+ save,
+
+ icon: postContent,
+} );
diff --git a/src/faq-answer/save.js b/src/faq-answer/save.js
new file mode 100644
index 0000000..911236b
--- /dev/null
+++ b/src/faq-answer/save.js
@@ -0,0 +1,22 @@
+/**
+ * WordPress dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+import { InnerBlocks } from '@wordpress/block-editor';
+
+export default function save( { attributes } ) {
+ const { isAccordion = true } = attributes;
+ const blockProps = useBlockProps.save( {
+ className: 'faq__panel',
+ } );
+
+ const divProps = isAccordion
+ ? { ...blockProps, role: 'region' }
+ : blockProps;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/faq-answer/style.scss b/src/faq-answer/style.scss
new file mode 100644
index 0000000..145582e
--- /dev/null
+++ b/src/faq-answer/style.scss
@@ -0,0 +1 @@
+// FAQ Answer styles
diff --git a/src/faq-item/block.json b/src/faq-item/block.json
new file mode 100644
index 0000000..93404aa
--- /dev/null
+++ b/src/faq-item/block.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "blockparty/faq-item",
+ "version": "1.0.2",
+ "title": "FAQ Item",
+ "category": "design",
+ "parent": [ "blockparty/faq" ],
+ "description": "A FAQ item containing a question and an answer.",
+ "supports": {
+ "html": false,
+ "inserter": false,
+ "reusable": false,
+ "innerBlocks": true
+ },
+ "textdomain": "blockparty-faq"
+}
diff --git a/src/faq-item/edit.js b/src/faq-item/edit.js
new file mode 100644
index 0000000..29f50f8
--- /dev/null
+++ b/src/faq-item/edit.js
@@ -0,0 +1,76 @@
+/**
+ * WordPress dependencies
+ */
+import { useBlockProps, BlockControls } from '@wordpress/block-editor';
+import { InnerBlocks } from '@wordpress/block-editor';
+import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
+import { addCard, trash } from '@wordpress/icons';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { createBlock } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+
+export default function Edit( { clientId } ) {
+ const blockProps = useBlockProps( {
+ className: 'faq__item',
+ } );
+
+ const { insertBlock, removeBlock } = useDispatch( 'core/block-editor' );
+ const { getBlockRootClientId, getBlockIndex, getBlock } = useSelect(
+ ( select ) => select( 'core/block-editor' ),
+ []
+ );
+
+ const rootClientId = getBlockRootClientId( clientId );
+ const blockIndex = getBlockIndex( clientId );
+
+ // Get isAccordion from parent block (faq)
+ const parentBlock = rootClientId ? getBlock( rootClientId ) : null;
+ const isAccordion =
+ parentBlock?.attributes?.isAccordion !== undefined
+ ? parentBlock.attributes.isAccordion
+ : true;
+
+ const onAddItem = () => {
+ const newItem = createBlock( 'blockparty/faq-item', {}, [
+ createBlock( 'blockparty/faq-question', { isAccordion } ),
+ createBlock( 'blockparty/faq-answer', { isAccordion } ),
+ ] );
+ insertBlock( newItem, blockIndex + 1, rootClientId );
+ };
+
+ const onRemoveItem = () => {
+ removeBlock( clientId );
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/faq-item/editor.scss b/src/faq-item/editor.scss
new file mode 100644
index 0000000..0666e29
--- /dev/null
+++ b/src/faq-item/editor.scss
@@ -0,0 +1 @@
+// FAQ Item editor styles
diff --git a/src/faq-item/index.js b/src/faq-item/index.js
new file mode 100644
index 0000000..ce30028
--- /dev/null
+++ b/src/faq-item/index.js
@@ -0,0 +1,26 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { list } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import './editor.scss';
+import Edit from './edit';
+import save from './save';
+
+registerBlockType( 'blockparty/faq-item', {
+ /**
+ * @see ./edit.js
+ */
+ edit: Edit,
+
+ /**
+ * @see ./save.js
+ */
+ save,
+ icon: list,
+} );
diff --git a/src/faq-item/save.js b/src/faq-item/save.js
new file mode 100644
index 0000000..3405ae0
--- /dev/null
+++ b/src/faq-item/save.js
@@ -0,0 +1,17 @@
+/**
+ * WordPress dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+import { InnerBlocks } from '@wordpress/block-editor';
+
+export default function save() {
+ const blockProps = useBlockProps.save( {
+ className: 'faq__item',
+ } );
+
+ return (
+
+
+
+ );
+}
diff --git a/src/faq-item/style.scss b/src/faq-item/style.scss
new file mode 100644
index 0000000..df13beb
--- /dev/null
+++ b/src/faq-item/style.scss
@@ -0,0 +1 @@
+// FAQ Item styles
diff --git a/src/faq-question/block.json b/src/faq-question/block.json
new file mode 100644
index 0000000..515474c
--- /dev/null
+++ b/src/faq-question/block.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "blockparty/faq-question",
+ "version": "1.0.2",
+ "title": "FAQ Question",
+ "category": "design",
+ "parent": [ "blockparty/faq-item" ],
+ "description": "Content of the FAQ question.",
+ "supports": {
+ "html": false,
+ "inserter": false,
+ "reusable": false
+ },
+ "attributes": {
+ "question": {
+ "type": "string",
+ "default": ""
+ },
+ "isAccordion": {
+ "type": "boolean",
+ "default": true
+ }
+ },
+ "textdomain": "blockparty-faq"
+}
diff --git a/src/faq-question/edit.js b/src/faq-question/edit.js
new file mode 100644
index 0000000..28c684f
--- /dev/null
+++ b/src/faq-question/edit.js
@@ -0,0 +1,105 @@
+/**
+ * WordPress dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+import { RichText, InnerBlocks } from '@wordpress/block-editor';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { createBlock } from '@wordpress/blocks';
+import { useEffect } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+const ALLOWED_BLOCKS_SIMPLE = [ 'core/heading', 'core/paragraph' ];
+
+export default function Edit( { attributes, setAttributes, clientId } ) {
+ const { question, isAccordion = true } = attributes;
+ const blockProps = useBlockProps( {
+ className: 'faq__title',
+ } );
+
+ const innerBlocks = useSelect(
+ ( select ) => select( 'core/block-editor' ).getBlocks( clientId ),
+ [ clientId ]
+ );
+
+ const { replaceInnerBlocks } = useDispatch( 'core/block-editor' );
+
+ // When isAccordion changes from true to false and innerBlocks are empty,
+ // create a core/heading block with the question attribute value
+ // When isAccordion changes from false to true, remove innerBlocks and
+ // transfer their content to the question attribute
+ useEffect( () => {
+ if ( ! isAccordion && innerBlocks.length === 0 && question ) {
+ // Switch from accordion to non-accordion: create heading block
+ const headingBlock = createBlock( 'core/heading', {
+ level: 3,
+ content: question,
+ } );
+ replaceInnerBlocks( clientId, [ headingBlock ] );
+ } else if ( isAccordion && innerBlocks.length > 0 ) {
+ // Switch from non-accordion to accordion: extract content from innerBlocks
+ // Get the text content from the first block (usually a heading or paragraph)
+ const firstBlock = innerBlocks[ 0 ];
+ let extractedContent = '';
+
+ if ( firstBlock.name === 'core/heading' ) {
+ extractedContent = firstBlock.attributes?.content || '';
+ } else if ( firstBlock.name === 'core/paragraph' ) {
+ extractedContent = firstBlock.attributes?.content || '';
+ }
+
+ // Update the question attribute with the extracted content
+ if ( extractedContent && extractedContent !== question ) {
+ setAttributes( { question: extractedContent } );
+ }
+
+ // Remove innerBlocks
+ replaceInnerBlocks( clientId, [] );
+ }
+ }, [
+ isAccordion,
+ innerBlocks,
+ question,
+ clientId,
+ replaceInnerBlocks,
+ setAttributes,
+ ] );
+
+ return (
+
+
+ { isAccordion ? (
+
+ setAttributes( { question: content } )
+ }
+ placeholder={ __( 'Question…?', 'blockparty-faq' ) }
+ allowedFormats={ [] }
+ />
+ ) : (
+
+ ) }
+
+
+ );
+}
diff --git a/src/faq-question/editor.scss b/src/faq-question/editor.scss
new file mode 100644
index 0000000..30e6659
--- /dev/null
+++ b/src/faq-question/editor.scss
@@ -0,0 +1 @@
+// FAQ Question editor styles
diff --git a/src/faq-question/index.js b/src/faq-question/index.js
new file mode 100644
index 0000000..4da75de
--- /dev/null
+++ b/src/faq-question/index.js
@@ -0,0 +1,26 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { listItem } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import './editor.scss';
+import Edit from './edit';
+import save from './save';
+
+registerBlockType( 'blockparty/faq-question', {
+ /**
+ * @see ./edit.js
+ */
+ edit: Edit,
+
+ /**
+ * @see ./save.js
+ */
+ save,
+ icon: listItem,
+} );
diff --git a/src/faq-question/save.js b/src/faq-question/save.js
new file mode 100644
index 0000000..8b2e835
--- /dev/null
+++ b/src/faq-question/save.js
@@ -0,0 +1,29 @@
+/**
+ * WordPress dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+import { RichText, InnerBlocks } from '@wordpress/block-editor';
+
+export default function save( { attributes } ) {
+ const { question, isAccordion = true } = attributes;
+ const blockProps = useBlockProps.save( {
+ className: 'faq__title',
+ } );
+
+ const TriggerTag = isAccordion ? 'button' : 'span';
+ const triggerProps = isAccordion
+ ? { className: 'faq__trigger', 'aria-expanded': 'false' }
+ : { className: 'faq__trigger' };
+
+ return (
+
+
+ { isAccordion ? (
+
+ ) : (
+
+ ) }
+
+
+ );
+}
diff --git a/src/faq-question/style.scss b/src/faq-question/style.scss
new file mode 100644
index 0000000..bab3577
--- /dev/null
+++ b/src/faq-question/style.scss
@@ -0,0 +1 @@
+// FAQ Question styles
diff --git a/src/faq/block.json b/src/faq/block.json
new file mode 100644
index 0000000..4c2d748
--- /dev/null
+++ b/src/faq/block.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "blockparty/faq",
+ "version": "1.0.2",
+ "title": "FAQ",
+ "category": "design",
+ "description": "A FAQ block for WordPress Editor that provided structured data based on FAQ schema.",
+ "supports": {
+ "html": false,
+ "innerBlocks": true
+ },
+ "textdomain": "blockparty-faq",
+ "editorScript": "file:./index.js",
+ "viewScript": "file:./script.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css"
+}
diff --git a/src/faq/deprecated.js b/src/faq/deprecated.js
new file mode 100644
index 0000000..fcd60bc
--- /dev/null
+++ b/src/faq/deprecated.js
@@ -0,0 +1,216 @@
+/**
+ * WordPress dependencies
+ */
+import { createBlock, parse } from '@wordpress/blocks';
+import { useBlockProps } from '@wordpress/block-editor';
+import { RichText } from '@wordpress/block-editor';
+
+/**
+ * Migration script to convert old FAQ format to new InnerBlocks format.
+ *
+ * Old format: questions array in attributes
+ * New format: InnerBlocks with faq-item blocks
+ *
+ * @param {Object} attributes The block attributes.
+ * @param {Array} innerBlocks The inner blocks.
+ * @return {Array} Tuple array [attributes, innerBlocks] when migrating to InnerBlocks.
+ */
+function migrate( attributes, innerBlocks ) {
+ // If no questions attribute, return existing innerBlocks (new format or empty block)
+ // According to WordPress documentation, when returning only innerBlocks,
+ // we should return a tuple: [attributes, innerBlocks]
+ if (
+ ! attributes.questions ||
+ ! Array.isArray( attributes.questions ) ||
+ attributes.questions.length === 0
+ ) {
+ // Return tuple: [attributes, innerBlocks]
+ return [
+ {
+ isAccordion:
+ attributes.isAccordion !== undefined
+ ? attributes.isAccordion
+ : true,
+ },
+ innerBlocks,
+ ];
+ }
+
+ const isAccordion =
+ attributes.isAccordion !== undefined ? attributes.isAccordion : true;
+
+ // Convert each question to a faq-item block
+ const migratedBlocks = attributes.questions.map( ( question ) => {
+ // Create faq-question block
+ const questionBlock = createBlock( 'blockparty/faq-question', {
+ question: question.question || '',
+ isAccordion: isAccordion,
+ } );
+
+ // Create faq-answer block with content
+ const answerContent = question.answer || '';
+
+ // Create inner blocks for the answer
+ let answerInnerBlocks = [];
+ if ( answerContent ) {
+ // Check if the answer contains HTML tags
+ const hasHtmlTags = /<[a-z][\s\S]*>/i.test( answerContent );
+
+ if ( hasHtmlTags ) {
+ // Answer contains HTML: try to parse it into blocks
+ try {
+ const parsedBlocks = parse( answerContent );
+
+ // If parsing succeeded and returned blocks, use them
+ if ( parsedBlocks && parsedBlocks.length > 0 ) {
+ answerInnerBlocks = parsedBlocks;
+ } else {
+ // Parsing didn't return blocks, create a paragraph with the HTML content
+ answerInnerBlocks = [
+ createBlock( 'core/paragraph', {
+ content: answerContent,
+ } ),
+ ];
+ }
+ } catch ( error ) {
+ // If parsing fails, create a paragraph with the content
+ answerInnerBlocks = [
+ createBlock( 'core/paragraph', {
+ content: answerContent,
+ } ),
+ ];
+ }
+ } else {
+ // Plain text: directly create a paragraph with the content
+ answerInnerBlocks = [
+ createBlock( 'core/paragraph', {
+ content: answerContent,
+ } ),
+ ];
+ }
+ } else {
+ // Empty answer, create empty paragraph
+ answerInnerBlocks = [ createBlock( 'core/paragraph' ) ];
+ }
+
+ const answerBlock = createBlock(
+ 'blockparty/faq-answer',
+ {
+ isAccordion: isAccordion,
+ },
+ answerInnerBlocks
+ );
+
+ // Create faq-item block containing question and answer
+ return createBlock( 'blockparty/faq-item', {}, [
+ questionBlock,
+ answerBlock,
+ ] );
+ } );
+
+ // Return new attributes (without questions) and migrated innerBlocks
+ // According to WordPress documentation, when migrating to InnerBlocks,
+ // migrate() must return a tuple array: [attributes, innerBlocks]
+ return [
+ {
+ isAccordion: isAccordion,
+ },
+ migratedBlocks,
+ ];
+}
+
+/**
+ * Check if a block is eligible for migration.
+ *
+ * @param {Object} attributes The block attributes.
+ * @param {Object} innerBlocks The inner blocks.
+ * @return {boolean} Whether the block should be migrated.
+ */
+function isEligible( attributes, innerBlocks ) {
+ // If questions attribute exists and has content, block needs migration
+ return (
+ attributes.questions &&
+ Array.isArray( attributes.questions ) &&
+ attributes.questions.length > 0
+ );
+}
+
+/**
+ * Save function for deprecated format.
+ * Matches the old HTML structure to allow proper block validation.
+ * The old format had: ...
+ *
+ * @param {Object} props Component props.
+ * @param {Object} props.attributes Block attributes.
+ * @return {JSX.Element} Saved block markup.
+ */
+function deprecatedSave( { attributes } ) {
+ const { questions = [] } = attributes;
+ const blockProps = useBlockProps.save();
+
+ if ( ! questions || questions.length === 0 ) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ { questions.map( ( item ) => (
+
+ ) ) }
+
+
+ );
+}
+
+/**
+ * Deprecated block configuration for migration from old format.
+ *
+ * Old format: questions array in attributes
+ * New format: InnerBlocks with faq-item blocks
+ */
+const deprecated = [
+ {
+ attributes: {
+ questions: {
+ type: 'array',
+ default: [],
+ },
+ isAccordion: {
+ type: 'boolean',
+ default: true,
+ },
+ },
+ supports: {
+ html: false,
+ innerBlocks: true,
+ },
+ isEligible,
+ migrate,
+ save: deprecatedSave,
+ },
+];
+
+export default deprecated;
diff --git a/src/faq/edit.js b/src/faq/edit.js
new file mode 100644
index 0000000..6016e58
--- /dev/null
+++ b/src/faq/edit.js
@@ -0,0 +1,101 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ BlockControls,
+ InnerBlocks,
+ useBlockProps,
+ InspectorControls,
+} from '@wordpress/block-editor';
+import {
+ ToolbarGroup,
+ ToolbarButton,
+ PanelBody,
+ ToggleControl,
+} from '@wordpress/components';
+import { addCard } from '@wordpress/icons';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { createBlock } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+import { useEffect } from '@wordpress/element';
+
+export default function Edit( { clientId, attributes, setAttributes } ) {
+ const { isAccordion = true } = attributes;
+ const blockProps = useBlockProps();
+
+ const { insertBlock, updateBlockAttributes } =
+ useDispatch( 'core/block-editor' );
+ const { getBlocks } = useSelect(
+ ( select ) => select( 'core/block-editor' ),
+ []
+ );
+
+ // Synchronize isAccordion attribute to all child blocks
+ useEffect( () => {
+ const innerBlocks = getBlocks( clientId );
+ innerBlocks.forEach( ( block ) => {
+ // Update faq-item blocks
+ if ( 'blockparty/faq-item' === block.name ) {
+ const itemInnerBlocks = getBlocks( block.clientId );
+ itemInnerBlocks.forEach( ( itemBlock ) => {
+ if (
+ ( 'blockparty/faq-question' === itemBlock.name ||
+ 'blockparty/faq-answer' === itemBlock.name ) &&
+ itemBlock.attributes.isAccordion !== isAccordion
+ ) {
+ updateBlockAttributes( itemBlock.clientId, {
+ isAccordion: isAccordion,
+ } );
+ }
+ } );
+ }
+ } );
+ }, [ isAccordion, clientId, getBlocks, updateBlockAttributes ] );
+
+ const onAddItem = () => {
+ const newItem = createBlock( 'blockparty/faq-item', {}, [
+ createBlock( 'blockparty/faq-question', { isAccordion } ),
+ createBlock( 'blockparty/faq-answer', { isAccordion } ),
+ ] );
+ insertBlock( newItem, undefined, clientId );
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ setAttributes( { isAccordion: value } )
+ }
+ __nextHasNoMarginBottom
+ />
+
+
+
+ >
+ );
+}
diff --git a/src/editor.scss b/src/faq/editor.scss
similarity index 100%
rename from src/editor.scss
rename to src/faq/editor.scss
diff --git a/src/faq/index.js b/src/faq/index.js
new file mode 100644
index 0000000..7689e23
--- /dev/null
+++ b/src/faq/index.js
@@ -0,0 +1,40 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { help } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import './editor.scss';
+import Edit from './edit';
+import save from './save';
+import deprecated from './deprecated';
+import metadata from './block.json';
+
+// Register child blocks first
+import '../faq-item';
+
+// Register parent block
+registerBlockType( metadata.name, {
+ ...metadata,
+ /**
+ * @see ./edit.js
+ */
+ edit: Edit,
+
+ /**
+ * @see ./save.js
+ */
+ save,
+
+ icon: help,
+
+ /**
+ * Migration from old format (questions array) to new format (InnerBlocks)
+ * @see ./deprecated.js
+ */
+ deprecated,
+} );
diff --git a/src/faq/save.js b/src/faq/save.js
new file mode 100644
index 0000000..f58f43b
--- /dev/null
+++ b/src/faq/save.js
@@ -0,0 +1,17 @@
+/**
+ * WordPress dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+import { InnerBlocks } from '@wordpress/block-editor';
+
+export default function save() {
+ const blockProps = useBlockProps.save();
+
+ return (
+
+ );
+}
diff --git a/src/faq/script.js b/src/faq/script.js
new file mode 100644
index 0000000..9990707
--- /dev/null
+++ b/src/faq/script.js
@@ -0,0 +1,12 @@
+import { Accordion } from '@beapi/be-a11y';
+
+// eslint-disable-next-line no-undef
+const accordionConfig = beapiFaqBlock.accordionConfig;
+
+// Initialize beapi-accordion
+window.addEventListener( 'load', function () {
+ Accordion.init(
+ '.wp-block-blockparty-faq:has(button.faq__trigger)',
+ accordionConfig
+ );
+} );
diff --git a/src/style.scss b/src/faq/style.scss
similarity index 100%
rename from src/style.scss
rename to src/faq/style.scss
diff --git a/src/index.js b/src/index.js
index bf8a76d..78bd1c0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,17 +1,11 @@
-import { registerBlockType } from '@wordpress/blocks';
-import './style.scss';
-import './editor.scss';
-import Edit from './edit';
-import save from './save';
+/**
+ * WordPress dependencies
+ */
-registerBlockType( 'blockparty/faq', {
- /**
- * @see ./edit.js
- */
- edit: Edit,
-
- /**
- * @see ./save.js
- */
- save,
-} );
+/**
+ * Internal dependencies
+ */
+import './faq';
+import './faq-item';
+import './faq-question';
+import './faq-answer';
diff --git a/src/save.js b/src/save.js
deleted file mode 100644
index d93cb30..0000000
--- a/src/save.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { RichText, useBlockProps } from '@wordpress/block-editor';
-
-export default function save( { attributes } ) {
- return (
-
-
- { attributes.questions.map( ( item ) => (
-
- ) ) }
-
-
- );
-}