, no JS, with
* FAQPage JSON-LD (#626).
*
* The markup and CSS classes are identical to the block's, so the three
* builders render the same accordion from the same stylesheet.
*
* Schema goes through Schema_Graph, not straight to the page. The graph collects
* this element's questions from Bricks' stored tree during `wp_head` — see
* `Schema_Graph::collect_bricks_faq()` — and merges them into the request's one
* FAQPage. By the time this renders, that has already happened, so emitting here
* as well would recreate the duplicate the graph exists to prevent (#355). The
* inline script below is the fallback for the requests where no graph is
* rendered at all.
*
* @package ThinkRank
* @subpackage Editor\Bricks
* @since 2.3.1
*/
declare(strict_types=1);
namespace ThinkRank\Editor\Bricks;
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
/**
* FAQ Element.
*
* @since 2.3.1
*/
class FAQ_Element extends \Bricks\Element {
/**
* Builder category.
*
* @var string
*/
public $category = 'thinkrank';
/**
* Element name, matching the Elementor widget's.
*
* @var string
*/
public $name = 'thinkrank-faq';
/**
* Panel icon (Themify, which Bricks bundles).
*
* @var string
*/
public $icon = 'ti-help-alt';
/**
* Label shown in the element panel.
*
* @return string
*/
public function get_label(): string {
return esc_html__('FAQ (ThinkRank)', 'thinkrank');
}
/**
* Panel search terms.
*
* @return array
*/
public function get_keywords(): array {
return ['faq', 'questions', 'accordion', 'schema', 'thinkrank'];
}
/**
* Load the shared block stylesheet, but only on a page using this element.
*
* @return void
*/
public function enqueue_scripts(): void {
wp_enqueue_style('thinkrank-faq-block');
}
/**
* Controls.
*
* @return void
*/
public function set_controls(): void {
$this->controls['heading'] = [
'label' => esc_html__('Section heading', 'thinkrank'),
'type' => 'text',
'default' => esc_html__('Frequently asked questions', 'thinkrank'),
];
$this->controls['headingTag'] = [
'label' => esc_html__('Heading tag', 'thinkrank'),
'type' => 'select',
'options' => [
'h2' => 'H2',
'h3' => 'H3',
'h4' => 'H4',
'p' => esc_html__('Paragraph', 'thinkrank'),
],
'default' => 'h2',
'inline' => true,
];
$this->controls['faqs'] = [
'label' => esc_html__('Questions', 'thinkrank'),
'type' => 'repeater',
'titleProperty' => 'question',
'fields' => [
'question' => [
'label' => esc_html__('Question', 'thinkrank'),
'type' => 'text',
],
'answer' => [
'label' => esc_html__('Answer', 'thinkrank'),
'type' => 'editor',
],
],
'default' => [
[
'question' => esc_html__('What is your refund policy?', 'thinkrank'),
'answer' => esc_html__('Describe the answer here.', 'thinkrank'),
],
],
];
$this->controls['firstOpen'] = [
'label' => esc_html__('Open first item by default', 'thinkrank'),
'type' => 'checkbox',
'default' => true,
];
$this->controls['outputSchema'] = [
'label' => esc_html__('Output FAQ schema (JSON-LD)', 'thinkrank'),
'type' => 'checkbox',
'default' => true,
'description' => esc_html__('Adds FAQPage structured data for rich results.', 'thinkrank'),
];
}
/**
* Render.
*
* @return void
*/
public function render(): void {
$settings = $this->settings;
$items = self::usable_items($settings);
if (empty($items)) {
// An element with nothing in it still needs a box in the builder,
// or the author cannot select what they just dropped on the canvas.
if (bricks_is_builder_call()) {
echo '
render_attributes('_root') . '>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. esc_html__('Add a question to get started.', 'thinkrank')
. '
';
}
return;
}
$this->set_attribute('_root', 'class', 'thinkrank-faq');
$heading_tag = self::heading_tag($settings);
$first_open = !empty($settings['firstOpen']);
$output = 'render_attributes('_root') . '>';
$heading = trim((string) ($settings['heading'] ?? ''));
if ('' !== $heading) {
$output .= sprintf(
'<%1$s class="thinkrank-faq__heading">%2$s%1$s>',
esc_html($heading_tag),
esc_html($this->render_dynamic_data($heading))
);
}
foreach ($items as $index => $faq) {
$answer = \Bricks\Helpers::parse_editor_content(
$this->render_dynamic_data((string) ($faq['answer'] ?? ''))
);
$output .= '
';
$output .= ''
. esc_html($this->render_dynamic_data((string) ($faq['question'] ?? '')))
. '
';
$output .= '' . wp_kses_post($answer) . '
';
$output .= ' ';
}
$output .= '
';
echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$this->maybe_render_schema($settings, $items);
}
/**
* The repeater rows worth rendering.
*
* Public so `Schema_Graph` reads the element exactly as it renders — one
* definition of "a usable FAQ item" for both the page and its schema.
*
* @since 2.3.1
* @param array $settings Element settings.
* @return array
*/
public static function usable_items(array $settings): array {
$faqs = is_array($settings['faqs'] ?? null) ? $settings['faqs'] : [];
return array_values(array_filter($faqs, static function ($faq) {
return is_array($faq) && (!empty($faq['question']) || !empty($faq['answer']));
}));
}
/**
* FAQPage question entities from this element's settings.
*
* Shared with the graph's collection pass so a question absorbed there and
* one printed here can never disagree.
*
* @since 2.3.1
* @param array $settings Element settings.
* @return array
*/
public static function question_entities(array $settings): array {
$entities = [];
foreach (self::usable_items($settings) as $faq) {
$question = trim(wp_strip_all_tags((string) ($faq['question'] ?? '')));
$answer = trim((string) ($faq['answer'] ?? ''));
if ('' === $question || '' === $answer) {
continue;
}
$entities[] = [
'@type' => 'Question',
'name' => $question,
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => wp_kses_post($answer),
],
];
}
return $entities;
}
/**
* Whether an element's settings arm its FAQ schema.
*
* Bricks stores a checkbox as `true` and clears it by dropping the key
* entirely, so an absent value is "off" — the control's `'default' => true`
* is written into the element's settings when it is created, which is why
* every Bricks element tests these with `isset()` rather than defaulting in
* PHP (see `animated-typing.php:185`). Defaulting to on here would make the
* toggle unclearable.
*
* @since 2.3.1
* @param array $settings Element settings.
* @return bool
*/
public static function schema_enabled(array $settings): bool {
return !empty($settings['outputSchema']);
}
/**
* Emit FAQPage JSON-LD, unless somebody else already has.
*
* @param array $settings Element settings.
* @param array $items Usable FAQ rows.
* @return void
*/
private function maybe_render_schema(array $settings, array $items): void {
// The Schema master switch and the matrix's per-content-type switch.
// This widget echoes its own '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/**
* A heading tag from the allowed set.
*
* @param array $settings Element settings.
* @return string
*/
private static function heading_tag(array $settings): string {
$tag = (string) ($settings['headingTag'] ?? 'h2');
return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
}
}