<?php
|
namespace JVBase\meta;
|
|
use JVBase\forms\TaxonomySelector;
|
use JVBase\forms\PostSelector;
|
use DateTime;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Renders the form fields for managing the meta
|
*/
|
class MetaForm
|
{
|
protected int $max_file_size = 5242880;
|
protected ?MetaTypeManager $type_manager = null;
|
|
/* ========== MAIN RENDER METHOD ========== */
|
public function return(string $name, mixed $value, array $config, bool $showHidden = false)
|
{
|
return $this->render($name, $value, $config, $showHidden, true);
|
}
|
public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false): mixed
|
{
|
$out = '';
|
if (jvbCheck('hidden', $config) && !$showHidden) {
|
return $out;
|
}
|
|
if (!array_key_exists('type', $config)) {
|
return $out;
|
}
|
if (!$value) {
|
$value = $this->getDefaultValue($config['type']);
|
}
|
|
// Handle hidden display type
|
if (array_key_exists('display', $config) && $config['display'] === 'hidden') {
|
$out = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
|
if (!$return) {
|
echo $out;
|
}
|
return $out;
|
}
|
|
ob_start();
|
|
// Try custom function overrides first
|
$type = array_map('ucfirst', explode('_', $config['type']));
|
$type = implode('', $type);
|
$method = 'render' . $type . 'Field';
|
|
$nameTemp = implode('', array_map('ucfirst', explode('_', $name)));
|
$nameMethod = 'render' . $nameTemp . 'Field';
|
|
if (function_exists($nameMethod)) {
|
call_user_func($nameMethod, $value, $config);
|
} elseif (function_exists($method)) {
|
call_user_func($method, $value, $config);
|
} elseif (method_exists($this, $method)) {
|
$this->$method($name, $value, $config);
|
}
|
|
$out = ob_get_clean();
|
|
do_action('jvbRenderFormField', $name, $config, $value);
|
$out = apply_filters('jvbFilterRenderFormField', $out, $name, $config, $value);
|
|
if (!$return) {
|
echo $out;
|
}
|
return $out;
|
}
|
|
public function getDefaultValue(string $type):mixed {
|
if (!$this->type_manager) {
|
$this->type_manager = new MetaTypeManager();
|
}
|
return match ($this->type_manager->getMetaType($type)) {
|
'object', 'array' => [],
|
'boolean' => false,
|
'integer' => 0,
|
default => '',
|
};
|
}
|
|
/* ========== HELPER METHODS ========== */
|
|
/**
|
* Prepare common field data
|
*/
|
protected function prepareFieldData(string $name, mixed $value, array $field): array
|
{
|
return [
|
'name' => array_key_exists('group', $field) ? $field['group'] . '::' . $name : $name,
|
'value' => isset($field['value']) ? $field['value'] : $value,
|
'id' => (array_key_exists('base', $field) ? esc_attr($field['base']) : '') . esc_attr($name),
|
];
|
}
|
|
/**
|
* Build common HTML attributes for inputs
|
*/
|
protected function buildInputAttributes(string $name, array $field): string
|
{
|
$attrs = [];
|
|
// Conditional rendering
|
if (array_key_exists('condition', $field)) {
|
$attrs['conditional'] = $this->handleConditionalField($field);
|
}
|
|
// Accessibility
|
if (!empty($field['description'])) {
|
$attrs['aria-describedby'] = $name . '-help';
|
}
|
|
// Common attributes
|
$common = ['placeholder', 'autocomplete', 'pattern', 'minlength', 'maxlength', 'min', 'max', 'step'];
|
foreach ($common as $attr) {
|
if (array_key_exists($attr, $field)) {
|
$attrs[$attr] = $field[$attr];
|
}
|
}
|
|
// Required
|
if (!empty($field['required'])) {
|
$attrs['required'] = true;
|
}
|
|
// Build attribute string
|
$attrString = '';
|
foreach ($attrs as $key => $val) {
|
if ($key === 'conditional') {
|
$attrString .= ' ' . $val; // Already formatted
|
} elseif ($val === true) {
|
$attrString .= ' ' . $key;
|
} else {
|
$attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
|
}
|
}
|
|
return $attrString;
|
}
|
|
/**
|
* Build validation data attributes
|
*/
|
protected function buildValidationAttributes(array $field): string
|
{
|
$attrs = [];
|
|
if (!empty($field['pattern'])) {
|
$attrs['data-pattern'] = $field['pattern'];
|
}
|
|
if (!empty($field['validate'])) {
|
$attrs['data-validate'] = $field['validate'];
|
}
|
|
if (isset($field['min'])) {
|
$attrs['data-min'] = $field['min'];
|
}
|
|
if (isset($field['max'])) {
|
$attrs['data-max'] = $field['max'];
|
}
|
|
if (isset($field['minlength'])) {
|
$attrs['data-minlength'] = $field['minlength'];
|
}
|
|
if (isset($field['maxlength'])) {
|
$attrs['data-maxlength'] = $field['maxlength'];
|
}
|
|
if (!empty($field['validation_message'])) {
|
$attrs['data-validation-message'] = $field['validation_message'];
|
}
|
|
$attrs['data-type'] = $field['type'];
|
|
$attrString = '';
|
foreach ($attrs as $key => $val) {
|
$attrString .= ' ' . $key . '="' . esc_attr($val) . '"';
|
}
|
|
return $attrString;
|
}
|
|
/* ========== GENERIC FIELD WRAPPER ========== */
|
|
/**
|
* Render a standard input field with validation wrapper
|
*/
|
protected function renderStandardInput(string $name, mixed $value, array $field, string $inputType = 'text'): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$inputAttrs = $this->buildInputAttributes($name, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
$pattern = array_key_exists('pattern', $field) ? $field['pattern'] : '';
|
$customData = '';
|
if (array_key_exists('data', $field) && !empty($field['data'])) {
|
foreach ($field['data'] as $key => $v) {
|
$customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
|
}
|
}
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>>
|
|
<?php $this->renderLabel($name, $field); ?>
|
|
<div class="field-input-wrapper">
|
<input
|
type="<?= esc_attr($inputType) ?>"
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>"
|
value="<?= esc_attr($data['value']) ?>"
|
<?= $inputAttrs ?>
|
<?= $customData?>
|
<?= $pattern?>
|
>
|
<span class="validation-icon success" hidden aria-hidden="true">
|
<?= jvbIcon('check-circle') ?>
|
</span>
|
<span class="validation-icon error" hidden aria-hidden="true">
|
<?= jvbIcon('x-circle') ?>
|
</span>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
/**
|
* Render field label with optional character count
|
*/
|
protected function renderLabel(string $name, array $field): void
|
{
|
?>
|
<label for="<?= esc_attr($name) ?>">
|
<?= esc_html($field['label']) ?>
|
<?php if (!empty($field['required'])) : ?>
|
<span class="required" aria-label="required">*</span>
|
<?php endif; ?>
|
<?php if (!empty($field['limit'])) : ?>
|
<span class="char-count" data-limit="<?= esc_attr($field['limit']) ?>">
|
<span class="current">0</span>/<?= esc_attr($field['limit']) ?>
|
</span>
|
<?php endif; ?>
|
</label>
|
<?php
|
}
|
|
/**
|
* Render hint and description
|
*/
|
protected function renderHintAndDescription(array $field, string $name): void
|
{
|
if (!empty($field['hint'])) {
|
$this->renderHint($field['hint']);
|
}
|
|
if (!empty($field['description'])) {
|
$this->renderDescription($field['description'], $name);
|
}
|
}
|
|
protected function renderHint(string $hint): void
|
{
|
?>
|
<span class="hint"><?= esc_html($hint) ?></span>
|
<?php
|
}
|
|
protected function renderDescription(string $description, string $name): void
|
{
|
?>
|
<p class="description" id="<?= esc_attr($name) ?>-help">
|
<?= wp_kses_post($description) ?>
|
</p>
|
<?php
|
}
|
|
/* ========== SIMPLE INPUT FIELD TYPES ========== */
|
|
public function renderTextField(string $name, mixed $value, array $field): void
|
{
|
$this->renderStandardInput($name, $value, $field, $field['subtype'] ?? 'text');
|
}
|
|
public function renderEmailField(string $name, mixed $value, array $field): void
|
{
|
$field['validate'] = 'email'; // Auto-add email validation
|
$this->renderStandardInput($name, $value, $field, 'email');
|
}
|
|
private function renderUrlField(string $name, mixed $value, array $field): void
|
{
|
$field['validate'] = 'url'; // Auto-add URL validation
|
$this->renderStandardInput($name, $value, $field, 'url');
|
}
|
|
private function renderTelField(string $name, mixed $value, array $field): void
|
{
|
$field['validate'] = 'phone'; // Auto-add phone validation
|
$this->renderStandardInput($name, $value, $field, 'tel');
|
}
|
|
private function renderDateField(string $name, mixed $value, array $field): void
|
{
|
$format = !empty($field['format']) ? $field['format'] : 'Y-m-d';
|
|
// Format the date if we have a value
|
if (!empty($value)) {
|
$date = DateTime::createFromFormat($format, $value);
|
if ($date) {
|
$value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format
|
}
|
}
|
|
$this->renderStandardInput($name, $value, $field, 'date');
|
}
|
|
private function renderTimeField(string $name, mixed $value, array $field): void
|
{
|
$this->renderStandardInput($name, $value, $field, 'time');
|
}
|
|
private function renderDatetimeField(string $name, mixed $value, array $field): void
|
{
|
$this->renderStandardInput($name, $value, $field, 'datetime-local');
|
}
|
|
/* ========== TEXTAREA FIELD ========== */
|
|
public function renderTextareaField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$inputAttrs = $this->buildInputAttributes($name, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
$rows = isset($field['rows']) ? (int)$field['rows'] : 4;
|
$quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : '';
|
|
if ($quill !== '') {
|
$allowImages = array_key_exists('allowImage', $field);
|
$quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"';
|
}
|
|
// Handle array values
|
if (is_array($value)) {
|
$value = implode(', ', $value);
|
}
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>>
|
|
<?php $this->renderLabel($name, $field); ?>
|
|
<div class="field-input-wrapper">
|
<textarea
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>"
|
rows="<?= esc_attr($rows) ?>"
|
<?= $quill ?>
|
<?= $inputAttrs ?>
|
><?= esc_textarea($data['value']) ?></textarea>
|
<span class="validation-icon success" hidden aria-hidden="true">
|
<?= jvbIcon('check-circle') ?>
|
</span>
|
<span class="validation-icon error" hidden aria-hidden="true">
|
<?= jvbIcon('x-circle') ?>
|
</span>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
/* ========== NUMBER FIELD ========== */
|
|
private function renderNumberField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
$min = isset($field['min']) ? (float)$field['min'] : 0;
|
$max = isset($field['max']) ? (float)$field['max'] : 100;
|
$step = isset($field['step']) ? (float)$field['step'] : 1;
|
|
// Handle custom data attributes
|
$customData = '';
|
if (array_key_exists('data', $field) && !empty($field['data'])) {
|
foreach ($field['data'] as $key => $v) {
|
$customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
|
}
|
}
|
|
if (empty($value)) {
|
$value = $field['default'] ?? 0;
|
}
|
|
$autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="' . $field['autocomplete'] . '"' : '';
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>>
|
|
<?php $this->renderLabel($name, $field); ?>
|
|
<div class="quantity" <?= $customData ?>>
|
<button type="button"
|
class="decrease"
|
title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
|
aria-label="Decrease <?= esc_attr($field['label']) ?>">
|
<?= jvbIcon('minus-square') ?>
|
</button>
|
|
<input type="number"
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>"
|
value="<?= esc_attr($value) ?>"
|
min="<?= esc_attr($min) ?>"
|
max="<?= esc_attr($max) ?>"
|
step="<?= esc_attr($step) ?>"
|
class="quantity-input"
|
<?= $describedBy ?>
|
<?= $autocomplete ?>
|
<?= !empty($field['required']) ? 'required' : '' ?>>
|
|
<button type="button"
|
class="increase"
|
title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
|
aria-label="Increase <?= esc_attr($field['label']) ?>">
|
<?= jvbIcon('plus-square') ?>
|
</button>
|
</div>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
/* ========== SELECT, RADIO, CHECKBOX FIELDS ========== */
|
|
private function renderSelectField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$inputAttrs = $this->buildInputAttributes($name, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>>
|
|
<?php $this->renderLabel($name, $field); ?>
|
|
<div class="field-input-wrapper">
|
<select
|
id="<?= esc_attr($data['id']) ?>"
|
name="<?= esc_attr($data['name']) ?>"
|
<?= $inputAttrs ?>>
|
<?php foreach ($field['options'] as $key => $label) : ?>
|
<option value="<?= esc_attr($key) ?>" <?php selected($value, $key); ?>>
|
<?= esc_html($label) ?>
|
</option>
|
<?php endforeach; ?>
|
</select>
|
<span class="validation-icon success" hidden aria-hidden="true">
|
<?= jvbIcon('check-circle') ?>
|
</span>
|
<span class="validation-icon error" hidden aria-hidden="true">
|
<?= jvbIcon('x-circle') ?>
|
</span>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
private function renderRadioField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>>
|
|
<fieldset>
|
<legend><?= esc_html($field['label']) ?>
|
<?php if (!empty($field['required'])) : ?>
|
<span class="required" aria-label="required">*</span>
|
<?php endif; ?>
|
</legend>
|
|
<?php foreach ($field['options'] as $key => $label) : ?>
|
<input
|
type="radio"
|
id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
|
name="<?= esc_attr($data['name']) ?>"
|
value="<?= esc_attr($key) ?>"
|
<?php checked($value, $key); ?>
|
<?= !empty($field['required']) ? 'required' : '' ?>
|
>
|
<label class="radio-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
|
<span><?= $label ?></span>
|
</label>
|
<?php endforeach; ?>
|
</fieldset>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
private function renderCheckboxField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
|
if (!is_array($value)) {
|
$value = !empty($value) ? [$value] : [];
|
}
|
|
?>
|
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>>
|
|
<fieldset>
|
<legend><?= esc_html($field['label']) ?>
|
<?php if (!empty($field['required'])) : ?>
|
<span class="required" aria-label="required">*</span>
|
<?php endif; ?>
|
</legend>
|
|
<?php foreach ($field['options'] as $key => $label) : ?>
|
<input
|
type="checkbox"
|
id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
|
name="<?= esc_attr($data['name']) ?>[]"
|
value="<?= esc_attr($key) ?>"
|
<?php checked(in_array($key, $value)); ?>
|
>
|
<label class="checkbox-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
|
<span><?= esc_html($label) ?></span>
|
</label>
|
<?php endforeach; ?>
|
</fieldset>
|
|
<span class="validation-message" hidden role="alert"></span>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
private function renderTrueFalseField(string $name, mixed $value, array $field): void
|
{
|
$data = $this->prepareFieldData($name, $value, $field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
?>
|
<div class="field true-false <?= esc_attr($name) ?> row btw"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>>
|
|
<label class="toggle-switch row" <?= $describedBy ?>>
|
<input
|
type="checkbox"
|
name="<?= esc_attr($data['name']) ?>"
|
value="1"
|
<?= ($value) ? ' checked' : '' ?>
|
<?= !empty($field['required']) ? 'required' : '' ?>
|
>
|
<div class="slider"></div>
|
<span class="toggle-label">
|
<?php if (!empty($field['required'])) : ?>
|
<span class="required" aria-label="required">*</span>
|
<?php endif; ?>
|
|
<?= esc_html($field['label']) ?></span>
|
</label>
|
<span class="validation-message" hidden role="alert"></span>
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
</div>
|
<?php
|
}
|
|
|
|
/* ========== REPEATER FIELD ========== */
|
|
private function renderRepeaterField(string $name, mixed $value, array $field):void
|
{
|
$values = is_array($value) ? $value : array();
|
|
$conditional = $this->handleConditionalField($field);
|
$row_label = isset($field['row_label']) ? $field['row_label'] : '';
|
$rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item';
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'].'::'.$name;
|
}
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
|
?>
|
<div class="field repeater <?=$name?>"
|
data-field="<?= esc_attr($name); ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $describedBy ?>
|
<?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?>
|
<?=$conditional?>>
|
<?php
|
if (!array_key_exists('label', $field)) {
|
error_log('No label for: '.print_r($name, true));
|
}
|
?>
|
<h3><?= esc_html($field['label']); ?></h3>
|
|
|
<div class="repeater-items">
|
<?php
|
if (!empty($values)) {
|
foreach ($values as $index => $row) {
|
$this->renderRepeaterRow($field['fields'], $row, $index, $name, $rowTitle);
|
}
|
}
|
?>
|
</div>
|
|
<template class="<?=uniqid('repeaterRow')?>">
|
<?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
|
</template>
|
|
<button type="button" class="add-repeater-row">
|
<?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
|
</button>
|
<?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
|
<?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
|
</div>
|
<?php
|
}
|
|
private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle = 'New Item'):void
|
{
|
$display_number = (is_string($index)) ? $index : ($index + 1);
|
?>
|
<div class="repeater-row" data-index="<?= esc_attr($index); ?>">
|
<details <?= (is_string($index)) ? 'open' : ''; ?>>
|
<summary class="repeater-row-header row btw">
|
<span class="drag-handle"><?= jvbIcon('dots-six-vertical'); ?></span>
|
<span class="row-number">#<?= esc_html($display_number); ?></span>
|
<span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span>
|
<button type="button" class="remove-row" title="Remove">
|
<?= jvbIcon('trash', ['title'=>'Remove']); ?>
|
</button>
|
</summary>
|
<div class="repeater-row-content">
|
<?php
|
foreach ($fields as $slug => $field) :
|
if ($base_name === '') {
|
$field_name = $slug;
|
} else {
|
$field_name = sprintf('%s:%s:%s', $base_name, $index, $slug);
|
}
|
$field_value = isset($values[$slug]) ? $values[$slug] : '';
|
$name = $field_name;
|
$this->render($name, $field_value, $field);
|
endforeach;
|
?>
|
</div>
|
</details>
|
</div>
|
<?php
|
}
|
|
private function getRowTitle(array $fields, array $values, string $rowTitle): string
|
{
|
// Try to find the first text field or textarea value to use as title
|
foreach ($fields as $slug => $field) {
|
if (in_array($field['type'], ['text', 'textarea']) &&
|
isset($values[$slug]) &&
|
!empty($values[$slug])) {
|
return $values[$slug];
|
}
|
}
|
return $rowTitle;
|
}
|
|
/* ========== GROUP FIELD ========== */
|
|
protected function renderGroupField(string $name, mixed $value, array $field): void
|
{
|
if (!array_key_exists('fields', $field) || empty($field['fields'])) {
|
error_log('No fields to render');
|
return;
|
}
|
|
|
$values = is_array($value) ? $value : [];
|
$original = $name;
|
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
$hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden');
|
|
if ($hidden) {
|
// Simplified render for hidden groups
|
$this->renderGroupFields($name, $values, $field);
|
return;
|
}
|
|
// Standard fieldset render
|
$conditional = $this->handleConditionalField($field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
$fieldset = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'details' : 'fieldset';
|
$legend = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'summary' : 'legend';
|
?>
|
<<?= $fieldset?> class="field group <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>
|
<?= $describedBy ?>>
|
<<?=$legend?>><?= esc_html($field['label']) ?></<?=$legend?>>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
|
<div class="group-fields <?= esc_attr($original) ?>">
|
<?php $this->renderGroupFields($name, $values, $field); ?>
|
</div>
|
|
<span class="validation-message" hidden role="alert"></span>
|
</<?= $fieldset?>>
|
<?php
|
}
|
|
/**
|
* Render individual fields within a group
|
* Reusable for both standard and hidden group modes
|
*/
|
private function renderGroupFields(string $groupName, array $values, array $field): void
|
{
|
foreach ($field['fields'] as $field_name => $config) {
|
// Set the group context for proper field naming
|
if (!array_key_exists('wrap', $field) || $field['wrap'] !== 'details') {
|
$config['group'] = $groupName;
|
}
|
|
// Get the value for this specific field
|
$field_value = $values[$field_name] ?? '';
|
|
// Handle conditional fields within the group
|
if (isset($config['condition'])) {
|
$condition_field = $config['condition']['field'];
|
if (!str_contains($condition_field, '::')) {
|
$config['condition']['field'] = $groupName . '::' . $condition_field;
|
}
|
}
|
|
$this->render($field_name, $field_value, $config);
|
}
|
}
|
|
/* ========== UPLOAD FIELD ========== */
|
private function renderGalleryField(string $name, mixed $value, array $field):void
|
{
|
$field['multiple'] = true;
|
$this->renderUploadField($name, $value, $field);
|
}
|
private function renderUploadField(string $name, mixed $value, array $field): void
|
{
|
$defaultConfig = [
|
//File Type
|
'subtype' => 'image', // 'image', 'video', 'document', 'any'
|
'accepted_types' => null, // null = use subtype defaults, or array of specific MIME types
|
//Upload Behaviour
|
'multiple' => false, // Single or multiple uploads
|
'limit' => 0, // Max number of uploads (0 = unlimited)
|
'mode' => 'direct', // 'direct' or 'selection'
|
//Destination
|
'destination' => 'meta', // 'meta', 'post', 'post_group'
|
//Processing Options
|
'max_size' => null, // Override default size limits
|
'convert' => 'webp', // Image conversion format
|
'quality' => 90, // Conversion quality
|
'create_thumbnails' => true,
|
];
|
$config = array_merge($defaultConfig, $field);
|
|
// Validate destination config
|
if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
|
error_log("Upload field '{$name}' has destination '{$config['destination']}' but no content defined");
|
return;
|
}
|
|
// Get accepted types
|
$acceptedTypes = $this->getAllowedTypes($config);
|
|
// Build accept attribute for input
|
$acceptExtensions = $this->getMimeExtensions($acceptedTypes);
|
$acceptAttr = implode(',', $acceptedTypes);
|
|
// Determine field attributes
|
$subtype = $config['subtype'] ?? 'image';
|
$multiple = $config['multiple'] ?? false;
|
$limit = $config['limit'] ?? 0;
|
$mode = $config['mode'] ?? 'direct';
|
$destination = $config['destination'];
|
|
// Get existing attachments
|
$attachmentIds = $this->parseAttachmentIds($value);
|
|
// Determine field type for UI
|
$fieldType = $multiple ? 'gallery' : 'single';
|
|
// Build data attributes
|
$dataAttrs = [
|
'data-field' => $name,
|
'data-upload-field' => '',
|
'data-mode' => $mode,
|
'data-type' => $fieldType,
|
'data-subtype' => $subtype,
|
'data-destination' => $destination,
|
];
|
if (!empty($field['content'])) {
|
$dataAttrs['data-content'] = $field['content'];
|
}
|
if ($limit > 0) {
|
$dataAttrs['data-limit'] = $limit;
|
}
|
|
// Build data attributes
|
$conditional = $this->handleConditionalField($field);
|
$describedBy = !empty($field['description']) ? ' aria-describedby="' . esc_attr($name) . '-help"' : '';
|
|
if (!empty($field['group'])) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
// Convert data attributes to string
|
$dataAttrString = '';
|
foreach ($dataAttrs as $attr => $val) {
|
$dataAttrString .= ' ' . $attr . ($val !== '' ? '="' . esc_attr($val) . '"' : '');
|
}
|
?>
|
<div class="field upload <?= esc_attr($name) ?>"
|
data-field="<?=esc_attr($name)?>"
|
data-field-type="upload"
|
<?= $dataAttrString ?>
|
<?= $conditional ?>>
|
|
<div class="file-upload-container">
|
<div class="file-upload-wrapper">
|
<input type="file"
|
name="<?= !empty($field['base']) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>_temp"
|
id="<?= !empty($field['base']) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>_temp"
|
accept="<?= esc_attr($acceptAttr) ?>"
|
data-max-size="<?= esc_attr($this->getMaxFileSize($subtype)) ?>"
|
<?= $multiple ? 'multiple' : '' ?>
|
<?= !empty($field['required']) ? 'required' : '' ?>>
|
|
<h2><?= esc_html($field['label']) ?></h2>
|
|
<?php if (!empty($field['description'])) : ?>
|
<p><?= esc_html($field['description']) ?></p>
|
<?php endif; ?>
|
|
<p class="file-upload-text">
|
<strong>Click to upload</strong> or drag and drop<br>
|
<?= esc_html($this->getAcceptedTypesLabel($subtype, $acceptExtensions)) ?>
|
(max. <?= esc_html($this->formatFileSize($this->getMaxFileSize($subtype))) ?>)
|
</p>
|
|
<?php if ($destination === 'post_group') {
|
$plural = (array_key_exists($field['content'], JVB_CONTENT)) ? JVB_CONTENT[$field['content']]['plural'] : (array_key_exists($field['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$field['content']]['plural'] : str_replace('_', ' ',$field['content']).'s');
|
$singular = (array_key_exists($field['content'], JVB_CONTENT)) ? JVB_CONTENT[$field['content']]['singular'] : (array_key_exists($field['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$field['content']]['singular'] : str_replace('_', ' ',$field['content']));
|
?>
|
<p class="hint">You can group images to create separate <?= $plural ?>.</p>
|
<p class="hint">If a <?=$singular?> has multiple images, you can select the <?= jvbIcon('star')?> to set an image as the main one.</p>
|
<?php }
|
if (!empty($field['upload_description'])) : ?>
|
<p><?= esc_html($field['upload_description']); ?></p>
|
<?php endif; ?>
|
<div class="file-error"></div>
|
</div>
|
<?php jvbRenderProgressBar(); ?>
|
</div>
|
|
|
<?php if ($destination === 'post_group') : ?>
|
<div class="group-display flex col" hidden>
|
<div class="preview-wrap flex col">
|
<div class="preview-actions">
|
<div class="selection-controls">
|
<div class="selected">
|
<div class="field">
|
<input type="checkbox" id="select-all-uploads" data-select-all data-selects="item-grid" name="select-all-uploads">
|
<label for="select-all-uploads">
|
Select All
|
</label>
|
</div>
|
<div class="info" hidden>
|
|
</div>
|
</div>
|
|
<div class="selection-actions row btw" hidden>
|
<button type="button" data-action="add-to-group">
|
<?= jvbIcon('plus-square') ?>
|
Group
|
</button>
|
<button type="button" data-action="delete-upload">
|
<?= jvbIcon('trash') ?>
|
Delete
|
</button>
|
</div>
|
</div>
|
|
<button type="button" data-action="upload" class="submit-uploads">
|
<?= jvbIcon('cloud-arrow-up') ?> Upload <?= esc_html($plural ?? 'Content'); ?>
|
</button>
|
</div>
|
<?php endif; ?>
|
|
<?php jvbRenderProgressBar('<span class="text">Processing files...</span>
|
<span class="count">0/0</span>'); ?>
|
<div class="item-grid preview">
|
<?php
|
// Render existing attachments
|
foreach ($attachmentIds as $attachmentId) {
|
echo $this->renderExistingAttachment($attachmentId, $subtype);
|
}
|
?>
|
</div>
|
|
<?php if ($destination === 'post_group') : ?>
|
<p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> These will become individual <?= $plural ?> <?= jvbIcon('arrow-elbow-right-up')?></p>
|
</div>
|
<div class="sidebar flex col">
|
<div class="header">
|
<h4>New <?= $plural?></h4>
|
<p class="hint">Drag or select multiple images into groups to create separate <?= $plural ?>.</p>
|
</div>
|
<div class="item-grid groups">
|
<div class="empty-group">
|
<p>Drag here to create a new <?= $singular ?>!</p>
|
</div>
|
</div>
|
<p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('arrow-elbow-right-up')?></p>
|
</div>
|
</div>
|
<?php endif; ?>
|
|
<?php if ($destination === 'meta') : ?>
|
<input type="hidden"
|
name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
|
value="<?= esc_attr($value); ?>"
|
<?= !empty($field['required']) ? 'required' : ''; ?>>
|
<?php endif; ?>
|
</div>
|
<?php
|
}
|
|
private function renderExistingAttachment(int $attachmentId, string $subtype): string
|
{
|
ob_start();
|
|
switch ($subtype) {
|
case 'image':
|
$this->renderImagePreview($attachmentId);
|
break;
|
case 'video':
|
$this->renderVideoPreview($attachmentId);
|
break;
|
case 'document':
|
case 'file':
|
$this->renderFilePreview($attachmentId);
|
break;
|
default:
|
$this->renderImagePreview($attachmentId);
|
break;
|
}
|
|
return ob_get_clean();
|
}
|
|
/**
|
* Get max file size for subtype
|
*/
|
private function getMaxFileSize(string $subtype): int
|
{
|
$sizes = [
|
'image' => 5242880, // 5MB
|
'video' => 104857600, // 100MB
|
'document' => 10485760 // 10MB
|
];
|
|
return $sizes[$subtype] ?? $sizes['image'];
|
}
|
|
/**
|
* Format file size for display
|
*/
|
private function formatFileSize(int $bytes): string
|
{
|
if ($bytes >= 1073741824) {
|
return number_format($bytes / 1073741824, 1) . 'GB';
|
}
|
if ($bytes >= 1048576) {
|
return number_format($bytes / 1048576, 1) . 'MB';
|
}
|
if ($bytes >= 1024) {
|
return number_format($bytes / 1024, 1) . 'KB';
|
}
|
return $bytes . 'B';
|
}
|
|
/**
|
* Get accepted types label
|
*/
|
private function getAcceptedTypesLabel(string $subtype, array $extensions): string
|
{
|
$labels = [
|
'image' => 'JPG, PNG, GIF, or WEBP',
|
'video' => 'MP4, WEBM, or MOV',
|
'document' => 'PDF, DOC, XLS, or TXT',
|
'any' => 'Images, Videos, or Documents'
|
];
|
|
return $labels[$subtype] ?? strtoupper(implode(', ', array_map(function($ext) {
|
return ltrim($ext, '.');
|
}, array_slice($extensions, 0, 3))));
|
}
|
|
/**
|
* Render upload preview items
|
*/
|
private function renderUploadPreviews(array $attachmentIds, array $config): void
|
{
|
if (empty($attachmentIds)) {
|
return;
|
}
|
|
foreach ($attachmentIds as $id) {
|
switch ($config['subtype']) {
|
case 'image':
|
$this->renderImagePreview($id, $config);
|
break;
|
case 'video':
|
$this->renderVideoPreview($id, $config);
|
break;
|
case 'file':
|
$this->renderFilePreview($id, $config);
|
break;
|
}
|
}
|
}
|
|
public function renderImagePreview(?int $id = null, array $config = []):void
|
{
|
$attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', false) : false;
|
$caption = ($id) ? wp_get_attachment_caption($id) : '';
|
$alt = ($id) ? get_post_meta($id, '_wp_attachment_image_alt',true) : '';
|
$title = ($id) ? get_the_title($id) : '';
|
$addID = ($id) ? '-'.$id : '';
|
$dataID = ($id) ? ['id' => $id] : '';
|
?>
|
<div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
|
<div class="preview">
|
<?php jvbRenderProgressBar('',true) ?>
|
<input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
|
<label for="select-item<?=$addID?>" aria-label="Select image">
|
<?= ($attachment) ?: '<img>
|
<video></video>
|
<span></span>' ?>
|
</label>
|
<div class="item-actions row btw">
|
<div class="radio-button">
|
<input type="radio" class="featured btn" name="featured" id="featured" hidden>
|
<label for="featured">
|
<?=jvbIcon('star')?>
|
<?=jvbIcon('star', ['style' => 'fill'])?>
|
<span class="screen-reader-text">Set as featured image</span>
|
</label>
|
</div>
|
|
<button type="button" data-action="delete-upload" title="Remove from Group">
|
<?=jvbIcon('trash')?>
|
</button>
|
</div>
|
</div>
|
<details>
|
<summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
|
|
<?php
|
|
$fields = [
|
'image_data' => [
|
'type' => 'group',
|
'wrap' => 'details',
|
'label' => 'Image Fields',
|
'fields' => [
|
'image-title'.$addID => [
|
'type' => 'text',
|
'label' => 'Image Title',
|
'value' => $title,
|
'data' => $dataID
|
],
|
'image-alt-text'.$addID => [
|
'type' => 'text',
|
'label' => 'Alt Text',
|
'value' => $alt,
|
'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
|
'data' => $dataID
|
],
|
'image-caption'.$addID => [
|
'type' => 'textarea',
|
'value' => $caption,
|
'label' => 'Image Caption',
|
'data' => $dataID
|
]
|
]
|
]
|
];
|
$fields = array_key_exists('fields', $config) ? array_merge($fields, $config['fields']) : $fields;
|
$meta = new MetaManager($id);
|
foreach ($fields as $field => $config) {
|
$meta->render('form', $field, $config);
|
}
|
?>
|
</details>
|
</div>
|
<?php
|
}
|
public function renderVideoPreview(?int $id = null, array $config = []):void
|
{
|
$attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
|
$caption = ($id) ? wp_get_attachment_caption($id) : '';
|
$description = ($id) ? get_the_content($id) : '';
|
$title = ($id) ? get_the_title($id) : '';
|
$addID = ($id) ? '-'.$id : '';
|
$dataID = ($id) ? ['id' => $id] : '';
|
?>
|
<div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
|
<div class="preview">
|
<?php jvbRenderProgressBar('',true) ?>
|
<input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
|
<label for="select-item<?=$addID?>" aria-label="Select image">
|
<?= ($attachment) ?: '<img>
|
<video></video>
|
<span></span>'; ?>
|
</label>
|
<div class="item-actions row btw">
|
<div class="radio-button">
|
<input type="radio" class="featured btn" name="featured" id="featured" hidden>
|
<label for="featured">
|
<?=jvbIcon('star')?>
|
<?=jvbIcon('star', ['style' => 'fill'])?>
|
<span class="screen-reader-text">Set as featured image</span>
|
</label>
|
</div>
|
|
<button type="button" data-action="delete-upload" title="Remove from Group">
|
<?=jvbIcon('trash')?>
|
</button>
|
</div>
|
</div>
|
<details>';
|
<summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
|
|
<?php
|
$fields = array_key_exists('fields', $config) ? $config['fields'] : [];
|
$fields = array_merge([
|
'upload_data' => [
|
'type' => 'group',
|
'wrap' => 'details',
|
'label' => 'Video Info',
|
'hint' => 'These will be automatically generated if left blank.',
|
'fields' => [
|
'title' => [
|
'type' => 'text',
|
'label' => 'Video Title',
|
'value' => $title,
|
'data' => $dataID
|
],
|
'caption' => [
|
'type' => 'textarea',
|
'value' => $caption,
|
'label' => 'Video Caption',
|
'data' => $dataID
|
],
|
'description' => [
|
'type' => 'textarea',
|
'value' => $description,
|
'label' => 'Video Description',
|
'data' => $dataID
|
]
|
]
|
]
|
], $fields);
|
$this->render('upload_data', null, $fields);
|
?>
|
</details>
|
</div>
|
<?php
|
}
|
public function renderFilePreview(?int $id = null, array $config = []):void
|
{
|
|
$attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
|
$caption = ($id) ? wp_get_attachment_caption($id) : '';
|
$description = ($id) ? get_the_content($id) : '';
|
$title = ($id) ? get_the_title($id) : '';
|
$addID = ($id) ? '-'.$id : '';
|
$dataID = ($id) ? ['id' => $id] : '';
|
?>
|
<div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
|
<div class="preview">
|
<?php jvbRenderProgressBar('',true) ?>
|
<input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
|
<label for="select-item<?=$addID?>" aria-label="Select image">
|
<?= ($attachment) ?: '<img>
|
<video></video>
|
<span></span>'; ?>
|
</label>
|
<div class="item-actions row btw">
|
<div class="radio-button">
|
<input type="radio" class="featured btn" name="featured" id="featured" hidden>
|
<label for="featured">
|
<?=jvbIcon('star')?>
|
<?=jvbIcon('star', ['style' => 'fill'])?>
|
<span class="screen-reader-text">Set as featured image</span>
|
</label>
|
</div>
|
|
<button type="button" data-action="delete-upload" title="Remove from Group">
|
<?=jvbIcon('trash')?>
|
</button>
|
</div>
|
</div>
|
<details>';
|
<summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
|
|
<?php
|
$fields = array_key_exists('fields', $config) ? $config['fields'] : [];
|
$fields = array_merge([
|
'upload_data' => [
|
'type' => 'group',
|
'wrap' => 'details',
|
'label' => 'File Info',
|
'hint' => 'These will be automatically generated if left blank.',
|
'fields' => [
|
'title' => [
|
'type' => 'text',
|
'label' => 'File Title',
|
'value' => $title,
|
'data' => $dataID
|
],
|
'caption' => [
|
'type' => 'textarea',
|
'value' => $caption,
|
'label' => 'File Caption',
|
'data' => $dataID
|
],
|
'description' => [
|
'type' => 'textarea',
|
'value' => $description,
|
'label' => 'File Description',
|
'data' => $dataID
|
]
|
]
|
]
|
], $fields);
|
$this->render('upload_data', null, $fields);
|
?>
|
</details>
|
</div>
|
<?php
|
}
|
|
/**
|
* Get upload instruction text based on config
|
*/
|
private function getUploadInstructions(array $config): string
|
{
|
$extensions = $this->getMimeExtensions($this->getAllowedTypes($config));
|
$extList = implode(', ', array_map('strtoupper', $extensions));
|
$maxSize = $config['max_size'] ?? $this->max_file_size;
|
$maxSizeMB = round($maxSize / 1048576, 1);
|
|
return "{$extList} (max. {$maxSizeMB}MB)";
|
}
|
|
/* ========== TAXONOMY/USER SELECTOR FIELDS ========== */
|
|
private function renderTaxonomyField(string $name, string $value, array $field): void
|
{
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
$this->renderSelectorField($name, $value, $field, 'taxonomy');
|
}
|
|
private function renderUserField(string $name, string $value, array $field): void
|
{
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
$this->renderSelectorField($name, $value, $field, 'post');
|
}
|
|
/**
|
* Generic selector field renderer
|
* Handles both taxonomy and post selectors with consistent structure
|
*/
|
public function renderSelectorField(string $name, mixed $value, array $field, string $type): void
|
{
|
$conditional = $this->handleConditionalField($field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
// Parse selected values
|
$value = (is_array($value)) ? array_filter(array_map('absint', $value)): $value;
|
$selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value));
|
|
// Generate unique container ID
|
$containerId = $name . '-' . $type . '-selector';
|
|
// Create selector instance with proper parameters
|
if ($type === 'taxonomy') {
|
$taxonomy = $field['taxonomy'];
|
$icon = JVB_TAXONOMY[$taxonomy]['icon']??'';
|
|
// Map field config to selector config
|
$selectorConfig = [
|
'max' => $field['max'] ?? 0, // 0 = unlimited
|
'search' => $field['search'] ?? true,
|
'label' => $field['label'] ?? '',
|
'createNew' => $field['createNew'] ?? false,
|
'required' => $field['required'] ?? false,
|
'base' => $field['base'] ?? '',
|
'update' => $field['update'] ?? true,
|
'name' => $name,
|
'autocomplete' => $field['autocomplete'] ?? false,
|
];
|
if ($icon !== '') {
|
$selectorConfig['icon'] = $icon;
|
}
|
|
$selector = new TaxonomySelector($containerId, $taxonomy, $selectorConfig);
|
$icon = $taxonomy;
|
} else {
|
$postType = $field['post_type'];
|
|
// Map field config to selector config
|
$selectorConfig = [
|
'max' => $field['max'] ?? 0,
|
'search' => $field['search'] ?? true,
|
'label' => $field['label'] ?? '',
|
'required' => $field['required'] ?? false,
|
'base' => $field['base'] ?? '',
|
'update' => $field['update'] ?? true,
|
'shop_id' => $field['shop_id'] ?? null,
|
'autocomplete'=> $field['autocomplete'] ?? true,
|
];
|
|
$selector = new PostSelector($containerId, $postType, $selectorConfig);
|
$icon = $postType;
|
}
|
|
?>
|
<div class="field selector <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
|
<?= $conditional ?>
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="selector"
|
data-type="<?=esc_attr($field['type'])?>"
|
<?= $validationAttrs ?>
|
<?= $describedBy ?>>
|
|
<?= $selector->render($selected) ?>
|
|
<!-- Hidden input for form submission -->
|
<input type="hidden"
|
class="<?= esc_attr($type) ?>-selector-input"
|
name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>"
|
data-<?= esc_attr($type) ?>="<?= esc_attr($field[$type === 'taxonomy' ? 'taxonomy' : 'post_type']) ?>"
|
value="<?= esc_attr(is_array($selected) ? implode(',', $selected) : $value) ?>"
|
<?= !empty($field['required']) ? 'required' : '' ?>>
|
|
<?php $this->renderHintAndDescription($field, $name); ?>
|
|
<span class="validation-message" hidden role="alert"></span>
|
</div>
|
<?php
|
}
|
|
/* ========== LOCATION FIELD ========== */
|
|
protected function renderLocationField(string $name, mixed $value, array $field): void
|
{
|
$googleMaps = JVB()->connect('maps');
|
if (!$googleMaps->isSetUp()) {
|
echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>';
|
return;
|
}
|
|
// Extract stored values
|
if (is_string($value)) {
|
$value = maybe_unserialize($value);
|
}
|
$stored_data = is_array($value) ? $value : [];
|
|
$address = $stored_data['address'] ?? '';
|
$lat = $stored_data['lat'] ?? '';
|
$lng = $stored_data['lng'] ?? '';
|
|
// Generate unique field ID
|
$field_id = esc_attr($name);
|
$map_id = $field_id . '_map';
|
|
// Handle grouped fields
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
|
|
// Prepare configuration for JavaScript initialization
|
$js_config = [
|
'fieldId' => $field_id,
|
'initialCoords' => (!empty($lat) && !empty($lng)) ? [
|
'lat' => (float)$lat,
|
'lng' => (float)$lng
|
] : null
|
];
|
|
// IMPORTANT: Properly escape the JSON for use in HTML attribute
|
$json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
|
?>
|
|
<div class="field location <?= esc_attr($field_id) ?>"
|
data-field="<?= esc_attr($field_id) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
|
|
<?php
|
if (!empty($stored_data['street'])) {
|
echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>';
|
echo '<p class="hint"><b>Search below to change:</b></p>';
|
}
|
?>
|
<?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
|
<?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
|
|
<div class="location-search-wrapper">
|
<div class="autocomplete-wrapper"></div>
|
|
<!-- Map container -->
|
<div class="location-preview">
|
<div id="<?= esc_attr($map_id); ?>"
|
class="location-map">
|
</div>
|
|
<?php if (!empty($stored_data)):
|
jvbLocationLinks($stored_data);
|
endif; ?>
|
</div>
|
|
<!-- Hidden inputs for data storage -->
|
<input type="hidden"
|
name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]"
|
value="<?= esc_attr($address); ?>"
|
data-location-field="address">
|
|
<input type="hidden"
|
name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]"
|
value="<?= esc_attr($lat); ?>"
|
data-location-field="lat">
|
|
<input type="hidden"
|
name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]"
|
value="<?= esc_attr($lng); ?>"
|
data-location-field="lng">
|
|
<?php
|
// Component fields
|
$components = ['street', 'city', 'province', 'postal_code', 'country'];
|
foreach ($components as $component):
|
?>
|
<input type="hidden"
|
name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]"
|
value="<?= esc_attr($stored_data[$component] ?? ''); ?>"
|
data-location-field="<?= esc_attr($component); ?>">
|
<?php endforeach; ?>
|
|
</div>
|
</div>
|
<?php
|
}
|
|
/* ========== HTML FIELD ========== */
|
|
protected function renderHtmlField(string $name, mixed $value, array $field): void
|
{
|
$method_name = $field['content'];
|
$content = '';
|
|
if (method_exists($this, $method_name)) {
|
$content = $this->$method_name();
|
}
|
|
if ($content === '') {
|
return;
|
}
|
|
echo sprintf(
|
'<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
|
esc_attr($name),
|
$content
|
);
|
}
|
|
/* ========== UTILITY METHODS ========== */
|
|
private function handleConditionalField(array $field):string
|
{
|
if (empty($field['condition'])) {
|
return '';
|
}
|
|
$condition = $field['condition'];
|
return sprintf(
|
'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"',
|
esc_attr($field['condition']['field']),
|
esc_attr($field['condition']['value']),
|
esc_attr($field['condition']['operator'] ?? '==')
|
);
|
}
|
|
protected function getAllowedTypes(array $config): array
|
{
|
if (!empty($config['accepted_types'])) {
|
return $config['accepted_types'];
|
}
|
|
// Default types based on subtype
|
$defaults = [
|
'image' => ['image/*'],
|
'video' => ['video/*'],
|
'document' => ['application/pdf', 'application/msword', 'application/vnd.ms-excel', 'text/plain', '.odt','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
];
|
$defaults['any'] = array_merge($defaults['image'], $defaults['video'], $defaults['document']);
|
|
return $defaults[$config['subtype']] ?? $defaults['image'];
|
}
|
|
protected function getMimeExtensions(array $mimeTypes): array
|
{
|
$extensions = [];
|
foreach ($mimeTypes as $mime) {
|
if (str_contains($mime, '*')) {
|
continue; // Skip wildcards
|
}
|
$ext = str_replace(['image/', 'video/', 'application/'], '', $mime);
|
$extensions[] = '.' . $ext;
|
}
|
return $extensions;
|
}
|
|
protected function parseAttachmentIds(mixed $value): array
|
{
|
if (empty($value)) {
|
return [];
|
}
|
|
if (is_array($value)) {
|
return array_filter($value, 'is_numeric');
|
}
|
|
if (is_string($value)) {
|
return array_filter(explode(',', $value), 'is_numeric');
|
}
|
|
return is_numeric($value) ? [$value] : [];
|
}
|
/**
|
* Render tag list field - inline tag input interface
|
*/
|
protected function renderTagListField(string $name, mixed $value, array $field): void
|
{
|
$values = is_array($value) ? $value : [];
|
$conditional = $this->handleConditionalField($field);
|
$validationAttrs = $this->buildValidationAttributes($field);
|
|
if (array_key_exists('group', $field)) {
|
$name = $field['group'] . '::' . $name;
|
}
|
|
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
|
|
// Tag display format - defaults to first field value
|
$tagFormat = $field['tag_format'] ?? 'first_field';
|
?>
|
<div class="field tag-list <?= esc_attr($name) ?>"
|
data-field="<?= esc_attr($name) ?>"
|
data-field-type="<?=esc_attr($field['type'])?>"
|
data-tag-format="<?= esc_attr($tagFormat) ?>"
|
<?= $describedBy ?>
|
<?= $conditional ?>
|
<?= $validationAttrs ?>>
|
|
<?php if (!empty($field['label'])): ?>
|
<h3><?= esc_html($field['label']) ?></h3>
|
<?php endif; ?>
|
|
<!-- Inline input row -->
|
<div class="tag-input-row">
|
<?php foreach ($field['fields'] as $subfield_name => $subfield_config): ?>
|
<?php
|
$subfield_config['label'] = $subfield_config['label'] ?? ucfirst($subfield_name);
|
$input_name = 'new_' . $subfield_name;
|
|
// Store required state but don't render it on the input
|
// This prevents form submission validation but allows JS validation
|
|
if (array_key_exists('required', $subfield_config)) {
|
$subfield_config['data']['required'] = true;
|
unset($subfield_config['required']); // Remove required for HTML rendering
|
}
|
$subfield_config['data']['ignore'] = true;
|
|
$this->render($input_name, '', $subfield_config, false, false);
|
?>
|
<?php endforeach; ?>
|
|
<button type="button" class="button add-tag-item">
|
<?= jvbIcon('plus') ?> <?= $field['add_label'] ?? 'Add' ?>
|
</button>
|
</div>
|
|
<!-- Tags display -->
|
<div class="tag-items">
|
<?php foreach ($values as $index => $item_data): ?>
|
<?php $this->renderTagItem($field['fields'], $item_data, $index, $name, $tagFormat); ?>
|
<?php endforeach; ?>
|
</div>
|
|
<!-- Template for new tags -->
|
<template class="<?=uniqid('tagListItem')?>">
|
<?php $this->renderTagItem($field['fields'], [], '', $name, $tagFormat); ?>
|
</template>
|
|
<?php if (!empty($field['hint'])): ?>
|
<?php $this->renderHint($field['hint']); ?>
|
<?php endif; ?>
|
|
<?php if (!empty($field['description'])): ?>
|
<?php $this->renderDescription($field['description'], $name); ?>
|
<?php endif; ?>
|
</div>
|
<?php
|
}
|
|
/**
|
* Render individual tag item
|
*/
|
protected function renderTagItem(array $fields, array $data, int|string $index, string $base_name, string $format): void
|
{
|
$tag_text = $this->getTagDisplayText($fields, $data, $format);
|
?>
|
<div class="tag-item" data-index="<?= esc_attr($index) ?>">
|
<span class="tag-label"><?= esc_html($tag_text) ?></span>
|
|
<!-- Hidden inputs for data -->
|
<?php foreach ($fields as $field_name => $field_config): ?>
|
<?php
|
$value = $data[$field_name] ?? '';
|
$full_name = is_string($index) ? $field_name : "{$base_name}:{$index}:{$field_name}";
|
?>
|
<input type="hidden"
|
name="<?= esc_attr($full_name) ?>"
|
value="<?= esc_attr($value) ?>"
|
data-field="<?= esc_attr($field_name) ?>"
|
data-field-type="<?=esc_attr($field_config['type'])?>" />
|
<?php endforeach; ?>
|
|
<button type="button" class="remove-tag" aria-label="Remove">
|
<?= jvbIcon('x') ?>
|
</button>
|
</div>
|
<?php
|
}
|
|
/**
|
* Get tag display text based on format
|
*/
|
protected function getTagDisplayText(array $fields, array $data, string $format): string
|
{
|
if (empty($data)) {
|
return 'New Item';
|
}
|
|
switch ($format) {
|
case 'first_field':
|
// Use the first field's value
|
$first_key = array_key_first($fields);
|
return $data[$first_key] ?? 'New Item';
|
|
case 'all_fields':
|
// Show all field values separated by commas
|
$values = array_filter(array_values($data));
|
return implode(', ', $values) ?: 'New Item';
|
|
case 'custom':
|
// Custom format - would need callback
|
return 'New Item';
|
|
default:
|
// Format is a template string like "{name} ({email})"
|
if (strpos($format, '{') !== false) {
|
$text = $format;
|
foreach ($data as $key => $value) {
|
$text = str_replace('{' . $key . '}', $value, $text);
|
}
|
return $text;
|
}
|
// Use specific field name
|
return $data[$format] ?? 'New Item';
|
}
|
}
|
}
|