Pular para o conteúdo principal

Acordeão

O bloco accordion-block combina um Fieldset estático de dois campos com uma interface dinâmica de itens gerida por JavaScript. O Fieldset expõe title e description. Os itens do acordeão entram e saem via accordion-block-admin.js, que injeta inputs de texto e textareas com CKEditor em runtime. Cada item é persistido em o:data[items][i][title|text] e a lista é reindexada ao remover um item.


Como foi construído

Identificação

ItemValor
ClasseSGMBlocks\Site\BlockLayout\AccordionBlock
block_layoutaccordion-block
FormSGMBlocks\Form\AccordionBlockForm
FactorySGMBlocks\Service\BlockLayout\AccordionBlockFactory
Partialcommon/block-layout/accordion-block

Formulário estático

AccordionBlockForm define dois campos. title e description são os campos do cabeçalho do bloco inteiro, não dos itens:

$this->add(['name' => 'o:block[__blockIndex__][o:data][title]',
'type' => Element\Text::class,
'options' => ['label' => 'Título']]);

$this->add(['name' => 'o:block[__blockIndex__][o:data][description]',
'type' => Element\Textarea::class,
'options' => ['label' => 'Descrição (opcional)'],
'attributes' => ['rows' => 3]]);

Os itens dinâmicos não aparecem nessa classe. Eles são gerados pelo JavaScript.

Classe do bloco

AccordionBlock::prepareForm é o ponto crítico. Ele anexa o admin.css para o estilo do gerenciador e o accordion-block-admin.js que cria e controla os itens:

public function prepareForm(PhpRenderer $view)
{
$view->headLink()->appendStylesheet($view->assetUrl('css/admin.css', 'SGMBlocks'));
$view->headScript()->appendFile($view->assetUrl('js/accordion-block-admin.js', 'SGMBlocks'));
}

AccordionBlock::form é incomum: além de formCollection do Fieldset, concatena HTML manual para o container dos itens, incluindo o array existente serializado em JSON num atributo data-items:

$items = [];
if ($block && $block->data()) {
$form->populateValues([
'o:block[__blockIndex__][o:data][title]' => $block->dataValue('title'),
'o:block[__blockIndex__][o:data][description]' => $block->dataValue('description'),
]);
$items = $block->dataValue('items') ?: [];
}

$formHtml = $view->formCollection($form);
$itemsJson = $view->escapeHtmlAttr(json_encode($items, JSON_HEX_APOS | JSON_HEX_QUOT));

$itemsHtml = <<<HTML
<div class="accordion-items-manager" data-items="{$itemsJson}">
<div class="field">
<div class="field-meta"><label>Itens do Acordeão</label></div>
<div class="inputs">
<div class="accordion-items-container"></div>
<button type="button" class="accordion-add-item o-icon-add button">Adicionar item</button>
</div>
</div>
</div>
HTML;

return $formHtml . $itemsHtml;

O JavaScript lê data-items, popula a UI e, a cada interação, atualiza os inputs para manter o formato aninhado o:block[__blockIndex__][o:data][items][i][title|text].

AccordionBlock::render passa items ao partial junto de title e description:

return $view->partial('common/block-layout/accordion-block', [
'title' => $block->dataValue('title'),
'description' => $block->dataValue('description'),
'items' => $block->dataValue('items') ?: [],
]);

Partial

<section class="sgm-accordion-block">
<?php if (!empty($title)): ?>
<h2 class="sgm-accordion-title"><?= $this->escapeHtml($title) ?></h2>
<?php endif; ?>

<?php if (!empty($description)): ?>
<p class="sgm-accordion-description"><?= $this->escapeHtml($description) ?></p>
<?php endif; ?>

<div class="sgm-accordion-list">
<?php foreach ($items as $i => $item): ?>
<?php if (empty($item['title'])) continue; ?>
<div class="sgm-accordion-item">
<button type="button" class="sgm-accordion-trigger"
aria-expanded="false"
aria-controls="sgm-accordion-panel-<?= $i ?>">
<span class="sgm-accordion-item-title"><?= $this->escapeHtml($item['title']) ?></span>
<span class="sgm-accordion-icon" aria-hidden="true">...</span>
</button>
<div id="sgm-accordion-panel-<?= $i ?>"
class="sgm-accordion-panel"
role="region"
hidden>
<div class="sgm-accordion-panel-content">
<?= $item['text'] ?? '' ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</section>

Observe: item['title'] usa escapeHtml, mas item['text'] sai sem escape porque vem do CKEditor. Essa decisão é deliberada para preservar marcação rica. O script inline do partial cuida do toggle via aria-expanded e do atributo hidden do painel.

JavaScript do admin

O accordion-block-admin.js é uma IIFE jQuery. Pontos técnicos:

  • Cada .accordion-items-manager é inicializado uma única vez. O guard usa data-accordion-initialized.
  • Itens existentes vêm do atributo data-items parseado com JSON.parse.
  • getBlockIndexdata-blockIndex do .block ancestral do gerenciador. Quando não existe, cai para o placeholder __blockIndex__.
  • addItem gera um .accordion-item-entry com um input de título e uma textarea com as classes block-html full wysiwyg, depois chama initWysiwyg para inicializar o CKEditor.
  • initWysiwyg chama $(textarea).ckeditor() e guarda a instância em data-ckeditorInstance para permitir destruição posterior.
  • reindexItems refaz os name dos inputs após remoções para manter a ordem items[0..n-1]. Antes de renomear, chama editor.updateElement() para sincronizar o valor do CKEditor no DOM.
  • O listener o:block-added reinicializa gerenciadores de blocos criados dinamicamente pelo editor do Omeka.

Trecho do addItem:

var $textarea = $("<textarea/>")
.attr("id", textareaId)
.attr("name", textName)
.addClass("block-html full wysiwyg")
.attr("rows", 6)
.text(text);

$fields.append($titleField, $textField);
$entry.append($header, $fields);
$container.append($entry);

initWysiwyg($textarea[0]);
itemIndex++;
updateLabels();

Persistência

Chave em o:dataTipoObservação
titlestringCabeçalho do bloco. escapeHtml no partial.
descriptionstringSubtítulo. escapeHtml no partial.
itemsarrayCada item é ['title' => string, 'text' => string HTML]. text sem escape.

Exemplo persistido:

{
"title": "Perguntas frequentes",
"description": "Dúvidas comuns sobre o acervo",
"items": [
{ "title": "Como acesso?", "text": "<p>Visite...</p>" },
{ "title": "Horários", "text": "<p>Segunda a sexta...</p>" }
]
}

Pipeline do JSON de itens

flowchart LR
SavedData["items em o:data"] --> PhpForm["AccordionBlock form"]
PhpForm --> DataItems["data-items JSON no HTML"]
DataItems --> JsInit["accordion-block-admin.js init"]
JsInit --> AddItem[addItem renderiza entry]
AddItem --> CkEditor[initWysiwyg cria CKEditor]
CkEditor --> Submit[submit do editor]
Submit --> OmekaPersist[Omeka grava items em o:data]

A cada ciclo de edição, o PHP serializa a lista para o JS, o JS renderiza e torna editável, o CKEditor atualiza o DOM antes do submit, e o Omeka persiste de volta.


Como usar como desenvolvedor

Adicionar um campo novo aos itens

Requer mudanças em três lugares:

  1. accordion-block-admin.js. Acrescente um input em addItem com name = 'o:block[' + bi + '][o:data][items][' + idx + '][nome_do_campo]'.
  2. AccordionBlock::form. Ajuste o data-items se o JSON salvar um valor pré-existente.
  3. accordion-block.phtml. Leia $item['nome_do_campo'] e renderize.

O Omeka persiste sozinho, desde que o name respeite o padrão aninhado.

Sobrescrever o partial no tema

Copie view/common/block-layout/accordion-block.phtml para themes/<tema>/view/common/block-layout/accordion-block.phtml. O tema pode trocar os ícones, usar <details> nativo em vez de <button>, ou aplicar animações CSS específicas.

Trocar o WYSIWYG por outra biblioteca

Substitua a classe wysiwyg por outro gatilho e reescreva initWysiwyg. Exemplo com Quill ou Trix exige carregar o JS da biblioteca no prepareForm e adaptar destroyWysiwyg para a API correspondente. Cuide do reindexItems para chamar o equivalente a editor.updateElement().

Customizar o toggle do frontend

O script inline do partial é mínimo. Para adicionar animação de altura, substitua a lógica:

document.querySelectorAll(".sgm-accordion-trigger").forEach(function (trigger) {
trigger.addEventListener("click", function () {
var expanded = this.getAttribute("aria-expanded") === "true";
this.setAttribute("aria-expanded", String(!expanded));
var panel = document.getElementById(this.getAttribute("aria-controls"));
if (panel) {
panel.hidden = expanded;
}
});
});

Troque panel.hidden por classes CSS e anime via transition: max-height se preferir transições suaves. Lembre-se de manter aria-expanded e aria-controls por acessibilidade.

Ler itens fora do bloco

foreach ($page->blocks() as $block) {
if ($block->layout() === 'accordion-block') {
$items = $block->dataValue('items') ?: [];
foreach ($items as $item) {
echo $item['title'], $item['text'];
}
}
}

Validar itens no submit

Anexe um listener a form.add_input_filters do AccordionBlockForm. O filter pode rodar Callback que percorre o array e descarta itens sem title. Alternativamente, filtre no próprio AccordionBlock::form antes de popular e no partial ao iterar.

Depurar

  • Se os itens somem ao salvar, inspecione o DOM antes do submit. Os name devem ser o:block[0][o:data][items][0][title] com números reais, nunca com __blockIndex__. Se aparecer __blockIndex__, o .block ancestral não tem data-blockIndex e o getBlockIndex caiu no fallback.
  • Se o CKEditor não inicializa, verifique a ordem de carregamento. O Omeka injeta CKEDITOR globalmente; o JS do acordeão precisa rodar depois.
  • Para debugar o JSON persistido, use a API do Omeka: api read site_pages e inspecione o:block do bloco de acordeão.

Observações

  • Itens sem título somem do render. O partial faz continue quando empty($item['title']). Isso é proposital, mas pode confundir editores.
  • text sem escape. O HTML do CKEditor entra direto no DOM. Qualquer conteúdo hostil colado no editor vai parar na página pública.
  • Ids dos paineis. O id é sgm-accordion-panel-{i}, onde {i} é o índice dentro do array. Se dois blocos de acordeão aparecem na mesma página, dois itens podem acabar com o mesmo id. Para deduplicar, prefixe com o id do bloco.
  • data-items com JSON_HEX_APOS e JSON_HEX_QUOT. O json_encode usa essas flags para evitar problemas ao interpolar dentro do atributo. Ao extrair no JS, JSON.parse ainda reconhece os códigos.
  • Reindexação. Remover o item 1 em uma lista de 3 faz o item 2 virar 1 e o 3 virar 2. reindexItems chama editor.updateElement() antes de trocar name porque o CKEditor só escreve no textarea quando pedido.