From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter
---
inc/meta/MetaForm.php | 2907 ++++++++++++++++++++++++++++-------------------------------
1 files changed, 1,377 insertions(+), 1,530 deletions(-)
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index 7e7e66d..cf1e401 100644
--- a/inc/meta/MetaForm.php
+++ b/inc/meta/MetaForm.php
@@ -6,7 +6,7 @@
use DateTime;
if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
+ exit;
}
/**
@@ -14,25 +14,31 @@
*/
class MetaForm
{
- protected int $max_file_size = 5242880;
+ protected int $max_file_size = 5242880;
+ protected ?MetaTypeManager $type_manager = null;
- //Rendering fields
- public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false):mixed
- {
+ /* ========== 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;
- }
- // Get conditional attributes if they exist
- $conditional = array_key_exists('condition', $config) ?
- $this->handleConditionalField($config) : '';
+ if (jvbCheck('hidden', $config) && !$showHidden) {
+ return $out;
+ }
- if (!array_key_exists('type', $config)) {
- return $out;
- }
+ if (!array_key_exists('type', $config)) {
+ return $out;
+ }
+ if (!$value) {
+ $value = $this->getDefaultValue($config['type']);
+ }
- if (array_key_exists('display', $config) && $config['display'] === 'hidden'){
- $out = '<input type="hidden" name="'.$name.'" value="'.$value.'" />';
+ // 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;
}
@@ -40,647 +46,1358 @@
}
ob_start();
- $type = array_map( 'ucfirst', explode('_', $config['type']));
- $type = implode('', $type);
- $method = 'render' . $type . 'Field';
+
+ // 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)) {
+ $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);
- }
-
-
+ $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 renderTextField(string $name, mixed $value, array $field):void
- {
- // Use field-specific value if provided, otherwise use the meta value
- $display_value = isset($field['value']) ? $field['value'] : $value;
- $conditional = $this->handleConditionalField($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- <?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>
- <input
- type="<?= esc_attr($field['subtype']??'text'); ?>"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($display_value); ?>"
- <?= $placeholder ?>
- <?= $autocomplete ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= $describedBy ?>
- >
- <?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
- if (array_key_exists('limit', $field)) {
- $this->outputCharacterCountJS();
- }
- }
+ 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 => '',
+ };
+ }
- private function renderTelField(string $name, mixed $value, array $field):void
- {$describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Use field-specific value if provided, otherwise use the meta value
- $display_value = isset($field['value']) ? $field['value'] : $value;
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- <?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>
- <input
- type="tel"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($display_value); ?>"
- <?= $placeholder ?>
- <?= $autocomplete?>
- <?= $describedBy ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <?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
- if (array_key_exists('limit', $field)) {
- $this->outputCharacterCountJS();
- }
- }
- private function renderEmailField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Use field-specific value if provided, otherwise use the meta value
- $display_value = isset($field['value']) ? $field['value'] : $value;
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- <?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>
- <input
- type="email"
- <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($display_value); ?>"
- <?= $placeholder ?>
- <?= $autocomplete ?>
- <?= $describedBy ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <?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
- if (array_key_exists('limit', $field)) {
- $this->outputCharacterCountJS();
- }
- }
+ /* ========== HELPER METHODS ========== */
- private function renderUrlField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Use field-specific value if provided, otherwise use the meta value
- $display_value = isset($field['value']) ? $field['value'] : $value;
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- <?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>
- <input
- type="url"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($display_value); ?>"
- <?= $placeholder ?>
- <?= $describedBy ?>
- <?= $autocomplete ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <?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
- if (array_key_exists('limit', $field)) {
- $this->outputCharacterCountJS();
- }
- }
+ /**
+ * 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),
+ ];
+ }
- private function renderNumberField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $description = '<ul class="list-none"><li>Tip: hold Ctrl/Command to increase 5x</li><li>Shift to increase 10x,</li><li>Or Ctrl/Command + Shift to increase 50x</li></ul>';
- $description .= $field['description']??'';
- $conditional = $this->handleConditionalField($field);
+ /**
+ * 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) : '';
+
+ ?>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ <?= $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 ?>
+ >
+ <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) ?>"
+ <?= $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;
- $data = '';
+ // Handle custom data attributes
+ $customData = '';
if (array_key_exists('data', $field) && !empty($field['data'])) {
- foreach($field['data'] as $key => $v) {
- if ($v === '') {
- $data .= ' data-'.$key;
- } else {
- $data .= ' data-'.$key.'="'.$v.'"';
- }
+ foreach ($field['data'] as $key => $v) {
+ $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
}
}
if (empty($value)) {
- $value = $field['default']??0;
+ $value = $field['default'] ?? 0;
}
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?> row" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
+ $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="' . $field['autocomplete'] . '"' : '';
- <div class="quantity"
- <?=$data?>>
-
- <button type="button"
- class="decrease"
- title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
- aria-label="Decrease <?= esc_attr($field['label']); ?>">
- <?= jvbIcon('minus')?>
- </button>
-
- <input type="number"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($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('add')?>
- </button>
- </div>
- <?php $this->renderDescription($description, $name); ?>
- </div>
- <?php
- }
-
- public function renderTextareaField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $rows = isset($field['rows']) ? (int)$field['rows'] : 4;
- $conditional = $this->handleConditionalField($field);
- $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);
- }
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : '';
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name ?? ''); ?>">
- <?= esc_html($field['label']); ?>
- <?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>
- <textarea
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>"
- <?= $quill ?>
- rows="<?= esc_attr($rows); ?>"
- <?= $placeholder ?>
- <?= $describedBy ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= !empty($field['limit']) ? 'data-limit="' . esc_attr($field['limit']) . '"' : ''; ?>
- ><?= esc_textarea($value); ?></textarea>
- <?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 renderSetField(string $name, mixed $value, array $field):void
- {
- $this->renderCheckboxField($name, $value, $field);
- }
-
- private function renderCheckboxField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $value = !is_array($value) ? explode(',', $value) : $value;
- $limit = isset($field['limit']) ? (int)$field['limit'] : 0;
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field checkbox-group" <?= $limit ? 'data-limit="' . esc_attr($limit) . '"' : ''; ?> <?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>>
- <span class="label"><?= esc_html($field['label']); ?></span>
- <div class="checkbox-options flex">
- <?php foreach ($field['options'] as $key => $label) : ?>
- <input
- type="checkbox"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key)?>"
- <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name)?>[]"
- value="<?= esc_attr($key); ?>"
- <?= (in_array($key, $value)) ? 'checked' : ''; ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <label class="checkbox-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>">
- <?= esc_html($label); ?>
- </label>
- <?php endforeach; ?>
- </div>
- <?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 renderRadioField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $value = (array)$value;
- $conditional = $this->handleConditionalField($field);
-
- if (!array_key_exists('label', $field)) {
- error_log('No label for: '.print_r($name, true));
- }
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field radio-group"<?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>>
- <label><?= esc_html($field['label']); ?></label>
-
- <div class="radio-options row">
- <?php foreach ($field['options'] as $key => $label) : ?>
- <input
- type="radio"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key) ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($key); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= (in_array($key, $value)) ? 'checked' : ''; ?>
- >
- <label class="radio-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>">
- <?= esc_html($label); ?>
- </label>
- <?php endforeach; ?>
- </div>
- <?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 renderRepeaterField(string $name, mixed $value, array $field):void
- {
- error_log('Rendering Repeater Field!');
- $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); ?>"
- <?= $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('repeaterTemplate')?>">
- <?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('grab'); ?></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('delete', ['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;
- }
-
- private function renderTaxonomyField(string $name, mixed $value, array $field):void
- {
- $conditional = $this->handleConditionalField($field);
- $taxonomy = $field['taxonomy'];
-
- // Get currently selected terms
- $selected_terms = ($value === '') ? [] : explode(',', $value);
-
-
- // Convert selected term IDs to the format expected by single modal
- $processedSelected = [];
- if (!empty($selected_terms)) {
- foreach ($selected_terms as $termId) {
- if (is_numeric($termId)) {
- $term = get_term($termId, $taxonomy);
- if ($term && !is_wp_error($term)) {
- $processedSelected[$term->term_id] = [
- 'name' => $term->name,
- 'path' => TaxonomySelector::getTermPath($term)
- ];
- }
- }
- }
- }
-
- // Create configuration for single modal system
- $config = [
- 'taxonomy' => $taxonomy,
- 'max' => $field['limit'] ?? 0,
- 'search' => $field['search'] ?? true,
- 'createNew' => $field['createNew'] ?? false,
- 'selected' => $processedSelected,
- 'base' => $field['base'] ?? '',
- ];
-
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
?>
- <div class="field taxonomy <?=$name?>" <?= $conditional ?> data-field="<?=$name?>">
- <div class="field-group-header">
- <label class="toggle">
- <?= jvbIcon(str_replace(BASE, '', $taxonomy)) ?>
- <?= esc_html($field['label']) ?>
- </label>
- </div>
+ <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ <?= $validationAttrs ?>>
- <?php
- $tax = new TaxonomySelector($name, $taxonomy, $config);
- $extra = '<input type="hidden"
- name="'.esc_attr($name).'"
- id="'.esc_attr($name).'"'.$describedBy.'
- data-taxonomy="'.esc_attr($taxonomy).'"
- value="'.esc_attr(is_array($selected_terms) ? implode(',', $selected_terms) : $selected_terms).'">';
- echo $tax->render([], $extra);
- ?>
+ <?php $this->renderLabel($name, $field); ?>
- <?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
- }
+ <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') ?>
+ </button>
- protected function renderPostSelectorField(string $name, mixed $value, array $field):void
- {
- $conditional = $this->handleConditionalField($field);
+ <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' : '' ?>>
- // Process selected posts
- $selected_posts = $value;
- if (is_string($selected_posts)) {
- $selected_posts = !empty($selected_posts) ? explode(',', $selected_posts) : [];
- } elseif (!is_array($selected_posts)) {
- $selected_posts = [];
- }
-
- // Configure the post selector
- $config = [
- 'multiple' => $field['multiple'] ?? true,
- 'maxSelections' => $field['limit'] ?? 0,
- 'search' => true,
- 'placeholder' => $field['placeholder'] ?? 'Search posts...',
- 'noResults' => 'No posts found',
- 'shop_id' => $field['shop_id'] ?? null,
- 'onClose' => 'updateMetaFormPost'
- ];
-
- $postSelector = new PostSelector($field['post_type'], $config);
- $containerId = $name . '-post-selector';
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- ?>
- <div class="field post-selector <?=$name?>" <?= $conditional ?> data-field="<?=$name?>">
- <div class="field-group-header">
- <label class="toggle">
- <?= jvbIcon($field['post_type'] . '-selector') ?>
- <?= esc_html($field['label'] ?? ucfirst($field['post_type'])) ?>
- </label>
- <button title="Add <?= esc_attr(ucfirst($field['post_type'])) ?>"
- class="add-item-btn"
- type="button">
- <?= jvbIcon('add', ['title' => "Add " . ucfirst($field['post_type'])]) ?>
+ <button type="button"
+ class="increase"
+ title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
+ aria-label="Increase <?= esc_attr($field['label']) ?>">
+ <?= jvbIcon('add') ?>
</button>
</div>
- <?= $postSelector->render($selected_posts, $containerId) ?>
+ <?php $this->renderHintAndDescription($field, $name); ?>
+ </div>
+ <?php
+ }
- <!-- Hidden input for form submission -->
- <input type="hidden"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name) ?>"
- class="post-selector-input"
- <?= $describedBy ?>
- data-post-type="<?= esc_attr($field['post_type']) ?>"
- value="<?= esc_attr(is_array($selected_posts) ? implode(',', $selected_posts) : $selected_posts) ?>">
+ /* ========== 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) ?>"
+ <?= $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) ?>"
+ <?= $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) ?>"
+ <?= $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) ?>"
+ <?= $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
+ {
+ error_log('Rendering Repeater Field!');
+ $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); ?>"
+ <?= $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('repeaterTemplate')?>">
+ <?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
- }
+ }
- protected function renderGroupField(string $name, mixed $value, array $field):void
+ 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('grab'); ?></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('delete', ['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'])) {
return;
}
- // Handle conditional fields
- $conditional = $this->handleConditionalField($field);
-
- // Ensure value is an array
$values = is_array($value) ? $value : [];
$original = $name;
+
if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
+ $name = $field['group'] . '::' . $name;
}
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
+
$hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden');
- if (!$hidden) {
- ?>
- <fieldset class="field group <?= esc_attr($name) ?>" <?= $conditional ?> data-field="<?=$name?>"<?= $describedBy?>>
- <legend><?= esc_html($field['label']) ?></legend>
- <?php
+
+ 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 class="field group <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ <?= $validationAttrs ?>
+ <?= $describedBy ?>>
+ <legend><?= esc_html($field['label']) ?></legend>
- <div class="group-fields <?=$original?>"<?= ($hidden) ? ' data-field="'.$name.'"' : ''?>"<?= $describedBy ?>>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- <?php
- foreach ($field['fields'] as $field_name => $config) {
- // Set the group context for proper field naming
- $config['group'] = $name;
+ <?php $this->renderHintAndDescription($field, $name); ?>
- // Get the value for this specific field
- $field_value = $values[$field_name] ?? '';
-
- // Handle conditional fields within the group
- if (isset($config['condition'])) {
- // Convert condition field reference to group context
- $condition_field = $config['condition']['field'];
- if (!str_contains($condition_field, '::')) {
- $config['condition']['field'] = $name . '::' . $condition_field;
- }
- }
-
- $this->render($field_name, $field_value, $config);
- }
- ?>
+ <div class="group-fields <?= esc_attr($original) ?>">
+ <?php $this->renderGroupFields($name, $values, $field); ?>
</div>
+
+ <span class="validation-message" hidden role="alert"></span>
+ </fieldset>
<?php
- if (!$hidden) {
- ?>
- </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
+ $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' => 80, // 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(',', $acceptExtensions);
+
+ // 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) ?>"
+ <?= $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>
+ </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" 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('add') ?>
+ Group
+ </button>
+ <button type="button" data-action="delete-upload">
+ <?= jvbIcon('delete') ?>
+ Delete
+ </button>
+ </div>
+ </div>
+
+ <button type="button" data-action="upload" class="submit-uploads">
+ <?= jvbIcon('upload') ?> 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('elbow-left-up') ?> These will become individual <?= $plural ?> <?= jvbIcon('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('elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('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
+ }
+
+ /**
+ * 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) ? $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('delete')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary>
+
+ <?php
+
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+ $fields = array_merge([
+ 'upload_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Image Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ '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);
+
+ $this->render('upload_data', null, $fields);
+ ?>
+ </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) ? $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('delete')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('edit')?><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) ? $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('delete')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('edit')?><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
+ */
+ private 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"' : '';
+
+ $isSimple = (array_key_exists('mode', $field) && $field['mode']==='simple');
+ // 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 <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
+ <?= $conditional ?>
+ data-field="<?= esc_attr($name) ?>"
+ <?= $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');
@@ -780,961 +1497,91 @@
</div>
<?php
}
- //TODO: This is more or less handled by PostSelector/TaxonomySelector, no?
- private function renderAssociationField(string $name, mixed $value, array $field):void
- {
- // Ensure value is an array
- if (!is_array($value)) {
- $value = empty($value) ? [] : [$value];
- }
- // Get field configuration
- $limit = isset($field['limit']) ? (int)$field['limit'] : 0;
- $object_types = isset($field['object_types']) ? $field['object_types'] : ['post'];
- $post_types = isset($field['post_types']) ? $field['post_types'] : ['post'];
- $taxonomies = isset($field['taxonomies']) ? $field['taxonomies'] : [];
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Create unique ID for this field
- $field_id = 'association-' . esc_attr($name);
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field association <?=$name?>" data-field="<?= esc_attr($name); ?>" <?= $conditional; ?>>
- <label><?= esc_html($field['label']); ?></label>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+ /* ========== HTML FIELD ========== */
- <div class="association-container"<?=$describedBy?>>
- <div class="association-search">
- <input type="text"
- id="<?= esc_attr($field_id); ?>-search"
- class="association-search-input"
- placeholder="Search items...">
-
- <div class="association-filter">
- <?php if (count($object_types) > 1 || count($post_types) > 1 || count($taxonomies) > 0) : ?>
- <select class="association-filter-select">
- <?php if (in_array('post', $object_types)) : ?>
- <?php foreach ($post_types as $post_type) : ?>
- <?php
- $post_type_obj = get_post_type_object($post_type);
- $label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst($post_type);
- ?>
- <option value="post:<?= esc_attr($post_type); ?>">
- <?= esc_html($label); ?>
- </option>
- <?php endforeach; ?>
- <?php endif; ?>
-
- <?php if (in_array('term', $object_types)) : ?>
- <?php foreach ($taxonomies as $taxonomy) : ?>
- <?php
- $tax_obj = get_taxonomy($taxonomy);
- $label = $tax_obj ? $tax_obj->labels->singular_name : ucfirst($taxonomy);
- ?>
- <option value="term:<?= esc_attr($taxonomy); ?>">
- <?= esc_html($label); ?>
- </option>
- <?php endforeach; ?>
- <?php endif; ?>
- </select>
- <?php endif; ?>
-
- <button type="button" class="search-button">
- <?= jvbIcon('search', ['title' => 'Search']); ?>
- </button>
- </div>
- </div>
-
- <div class="association-results">
- <div class="association-available">
- <h4>Available Items</h4>
- <ul class="available-items"></ul>
- <div class="association-loading" hidden>
- Loading...
- </div>
- <div class="association-no-results" hidden>
- No items found
- </div>
- <div class="association-pagination">
- <button type="button" class="prev-page" disabled>
- <?= jvbIcon('arrow-left', ['title' => 'Previous']); ?>
- </button>
- <span class="page-info">Page <span class="current-page">1</span></span>
- <button type="button" class="next-page" disabled>
- <?= jvbIcon('arrow-right', ['title' => 'Next']); ?>
- </button>
- </div>
- </div>
-
- <div class="association-actions">
- <button type="button" class="add-selected" disabled>
- <?= jvbIcon('arrow-right', ['title' => 'Add selected']); ?>
- </button>
- <button type="button" class="remove-selected" disabled>
- <?= jvbIcon('arrow-left', ['title' => 'Remove selected']); ?>
- </button>
- </div>
-
- <div class="association-selected">
- <h4>Selected Items
- <?php if ($limit) : ?>
- <span class="limit-info">(<?= esc_html($limit); ?> max)</span>
- <?php endif; ?>
- </h4>
- <ul class="selected-items row">
- <?php
- // Display currently selected items
- foreach ($value as $item_id) {
- // Try to determine the type and get details
- $item_type = '';
- $item_title = '';
- $item_object = '';
-
- // Check if it's a post
- if (in_array('post', $object_types)) {
- $post = get_post($item_id);
- if ($post && in_array($post->post_type, $post_types)) {
- $item_type = 'post';
- $item_title = $post->post_title;
- $item_object = $post->post_type;
- }
- }
-
- // Check if it's a term
- if (empty($item_type) && in_array('term', $object_types)) {
- foreach ($taxonomies as $taxonomy) {
- $term = get_term($item_id, $taxonomy);
- if (!is_wp_error($term) && $term) {
- $item_type = 'term';
- $item_title = $term->name;
- $item_object = $term->taxonomy;
- break;
- }
- }
- }
-
- // Only output if we found the item
- if (!empty($item_type) && !empty($item_title)) {
- ?>
- <li data-id="<?= esc_attr($item_id); ?>"
- data-type="<?= esc_attr($item_type); ?>"
- data-object="<?= esc_attr($item_object); ?>">
- <span class="item-title"><?= esc_html($item_title); ?></span>
- <span class="item-type"><?= esc_html(ucfirst($item_object)); ?></span>
- <button type="button" class="remove-item row">
- <?= jvbIcon('close', ['title' => 'Remove']); ?>
- </button>
- </li>
- <?php
- }
- }
- ?>
- </ul>
- </div>
- </div>
- </div>
-
- <!-- Hidden input to store selected values -->
- <input type="hidden" name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" value="<?= esc_attr(implode(',', $value)); ?>">
- </div>
-
- <script>
- (function() {
- // Initialize association field
- const container = document.querySelector('[data-field="<?= esc_attr($name); ?>"]');
- if (!container) return;
-
- const searchInput = container.querySelector('.association-search-input');
- const filterSelect = container.querySelector('.association-filter-select');
- const searchButton = container.querySelector('.search-button');
- const availableList = container.querySelector('.available-items');
- const selectedList = container.querySelector('.selected-items');
- const hiddenInput = container.querySelector('input[name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"]');
- const addButton = container.querySelector('.add-selected');
- const removeButton = container.querySelector('.remove-selected');
- const loadingIndicator = container.querySelector('.association-loading');
- const noResultsMessage = container.querySelector('.association-no-results');
- const prevPageButton = container.querySelector('.prev-page');
- const nextPageButton = container.querySelector('.next-page');
- const currentPageSpan = container.querySelector('.current-page');
-
- // Configuration
- const config = {
- limit: <?= $limit ?: 0; ?>,
- objectTypes: <?= json_encode($object_types); ?>,
- postTypes: <?= json_encode($post_types); ?>,
- taxonomies: <?= json_encode($taxonomies); ?>,
- perPage: 10,
- currentPage: 1
- };
-
- // Current state
- let currentSearch = '';
- let currentFilter = filterSelect ? filterSelect.value : (config.objectTypes.includes('post') ? 'post:' + config.postTypes[0] : 'term:' + config.taxonomies[0]);
- let availableItems = [];
- let selectedItems = [];
-
- // Parse initial selected items
- const initialValue = hiddenInput.value;
- if (initialValue) {
- selectedItems = initialValue.split(',').map(id => parseInt(id, 10));
- }
-
- // Event Listeners
- if (searchButton) {
- searchButton.addEventListener('click', performSearch);
- }
-
- if (searchInput) {
- searchInput.addEventListener('keypress', function(e) {
- if (e.key === 'Enter') {
- e.preventDefault();
- performSearch();
- }
- });
- }
-
- if (filterSelect) {
- filterSelect.addEventListener('change', function() {
- currentFilter = this.value;
- config.currentPage = 1;
- performSearch();
- });
- }
-
- if (prevPageButton) {
- prevPageButton.addEventListener('click', function() {
- if (config.currentPage > 1) {
- config.currentPage--;
- performSearch();
- }
- });
- }
-
- if (nextPageButton) {
- nextPageButton.addEventListener('click', function() {
- config.currentPage++;
- performSearch();
- });
- }
-
- // Add items
- addButton.addEventListener('click', function() {
- const selectedAvailableItems = availableList.querySelectorAll('li.selected');
- selectedAvailableItems.forEach(item => {
- const id = parseInt(item.dataset.id, 10);
- // Check limit
- if (config.limit && selectedItems.length >= config.limit) {
- return;
- }
-
- // Skip if already selected
- if (selectedItems.includes(id)) {
- return;
- }
-
- // Add to selection
- selectedItems.push(id);
-
- // Clone and modify for selected list
- const clone = item.cloneNode(true);
- clone.classList.remove('selected');
-
- // Replace checkbox with remove button
- const checkbox = clone.querySelector('input[type="checkbox"]');
- if (checkbox) {
- const removeBtn = document.createElement('button');
- removeBtn.type = 'button';
- removeBtn.className = 'remove-item';
- removeBtn.innerHTML = '<?= jvbIcon('close', ['title' => 'Remove']); ?>';
- removeBtn.addEventListener('click', function() {
- removeItem(id, clone);
- });
-
- checkbox.parentNode.replaceChild(removeBtn, checkbox);
- }
-
- selectedList.appendChild(clone);
- });
-
- // Update hidden input
- updateHiddenInput();
-
- // Update UI state
- updateButtonStates();
- });
-
- // Remove items
- removeButton.addEventListener('click', function() {
- const selectedSelectedItems = selectedList.querySelectorAll('li.selected');
- selectedSelectedItems.forEach(item => {
- const id = parseInt(item.dataset.id, 10);
- removeItem(id, item);
- });
- });
-
- // Listen for clicks on items in both lists
- availableList.addEventListener('click', function(e) {
- const item = e.target.closest('li');
- if (!item) return;
-
- // If clicking checkbox, handle separately
- if (e.target.type === 'checkbox') {
- updateButtonStates();
- return;
- }
-
- // Toggle selection
- if (item.classList.contains('selected')) {
- item.classList.remove('selected');
- item.querySelector('input[type="checkbox"]').checked = false;
- } else {
- item.classList.add('selected');
- item.querySelector('input[type="checkbox"]').checked = true;
- }
-
- updateButtonStates();
- });
-
- selectedList.addEventListener('click', function(e) {
- const item = e.target.closest('li');
- if (!item) return;
-
- // If clicking remove button, handle it
- if (e.target.closest('.remove-item')) {
- const id = parseInt(item.dataset.id, 10);
- removeItem(id, item);
- return;
- }
-
- // Toggle selection
- item.classList.toggle('selected');
- updateButtonStates();
- });
-
- // Helper Functions
- function performSearch() {
- currentSearch = searchInput.value.trim();
-
- // Show loading
- loadingIndicator.hidden = false;
- noResultsMessage.hidden = true;
- availableList.innerHTML = '';
-
- // Get filter parts
- const [type, object] = currentFilter.split(':');
-
- // Prepare data for AJAX
- const data = {
- action: 'jvb_association_search',
- nonce: jvbSettings.nonce,
- type: type,
- object: object,
- search: currentSearch,
- page: config.currentPage,
- per_page: config.perPage,
- selected: selectedItems
- };
-
- // Make AJAX request to WordPress REST API
- fetch(jvbSettings.api + 'terms', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': jvbSettings.nonce
- },
- body: JSON.stringify(data)
- })
- .then(response => response.json())
- .then(response => {
- // Hide loading
- loadingIndicator.hidden = true;
-
- if (response.success && response.items && response.items.length > 0) {
- // Update available items
- availableItems = response.items;
-
- // Render items
- renderAvailableItems();
-
- // Update pagination
- updatePagination(response.total, response.pages);
- } else {
- // Show no results
- noResultsMessage.hidden = false;
- prevPageButton.disabled = true;
- nextPageButton.disabled = true;
- currentPageSpan.textContent = '1';
- }
- })
- .catch(error => {
- console.error('Error searching items:', error);
- loadingIndicator.hidden = true;
- noResultsMessage.hidden = false;
- });
- }
-
- function renderAvailableItems() {
- availableList.innerHTML = '';
-
- availableItems.forEach(item => {
- const isSelected = selectedItems.includes(item.id);
-
- const li = document.createElement('li');
- li.dataset.id = item.id;
- li.dataset.type = item.type;
- li.dataset.object = item.object;
-
- // Create checkbox
- const checkbox = document.createElement('input');
- checkbox.type = 'checkbox';
- checkbox.id = `${name}-item-${item.id}`;
-
- // Create label for title
- const titleSpan = document.createElement('span');
- titleSpan.className = 'item-title';
- titleSpan.textContent = item.title;
-
- // Create label for type
- const typeSpan = document.createElement('span');
- typeSpan.className = 'item-type';
- typeSpan.textContent = item.object_label;
-
- // Append elements
- li.appendChild(checkbox);
- li.appendChild(titleSpan);
- li.appendChild(typeSpan);
-
- // Disable if already selected
- if (isSelected) {
- li.classList.add('disabled');
- checkbox.disabled = true;
-
- // Add note that item is already selected
- const note = document.createElement('span');
- note.className = 'item-note';
- note.textContent = 'Already selected';
- li.appendChild(note);
- }
-
- availableList.appendChild(li);
- });
- }
-
- function updatePagination(total, pages) {
- // Update current page display
- currentPageSpan.textContent = config.currentPage;
-
- // Update prev/next buttons
- prevPageButton.disabled = config.currentPage <= 1;
- nextPageButton.disabled = config.currentPage >= pages;
- }
-
- function removeItem(id, element) {
- // Remove from array
- selectedItems = selectedItems.filter(itemId => itemId !== id);
-
- // Remove from DOM
- if (element) {
- element.remove();
- }
-
- // Update hidden input
- updateHiddenInput();
-
- // Update buttons
- updateButtonStates();
- }
-
- function updateHiddenInput() {
- hiddenInput.value = selectedItems.join(',');
- }
-
- function updateButtonStates() {
- // Add button is enabled if at least one available item is selected
- // and we haven't reached the limit
- const hasSelectedAvailable = availableList.querySelector('li.selected:not(.disabled)') !== null;
- addButton.disabled = !hasSelectedAvailable ||
- (config.limit > 0 && selectedItems.length >= config.limit);
-
- // Remove button is enabled if at least one selected item is selected
- const hasSelectedItems = selectedList.querySelector('li.selected') !== null;
- removeButton.disabled = !hasSelectedItems;
- }
-
- // Initial search
- performSearch();
- })();
- </script>
- <?php
- }
-
- private function renderTrueFalseField(string $name, mixed $value, array $field):void
- {
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- ?>
- <div class="field true-false <?=$name?> row btw" <?=$conditional?> data-field="<?=$name?>">
- <label class="toggle-switch row"<?=$describedBy?>>
- <input
- type="checkbox"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="1"
- <?= ($value) ? ' checked':'' ?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <div class="slider"></div>
- <span class="toggle-label"><?= esc_html($field['label']); ?></span>
- </label>
- <?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 renderImageField(string $name, mixed $value, array $field):void
- {
- $image_url = $title = $alt = $caption = false;
- if ($value !== 0 || $value !== '') {
- $image_url = wp_get_attachment_image_url((int)$value, 'medium') ?: false;
- $caption = wp_get_attachment_caption((int)$value);
- $alt = get_post_meta((int)$value, '_wp_attachment_image_alt', true);
- $title = get_the_title((int)$value);
- }
-
- $mode = array_key_exists('mode', $field) ? $field['mode'] : 'direct';
- $multiple = ($mode === 'selection' || isset($field['multiple']));
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- $groupable = (array_key_exists('imageType', $field) && $field['imageType'] === 'groupable');
- $singular = (array_key_exists('singular', $field)) ? $field['singular'] : 'post';
- $plural = (array_key_exists('plural', $field)) ? $field['plural'] : 'posts';
- $dataContent = (array_key_exists('content', $field)) ? ' data-content="'.$field['content'].'"' : '';
- $dataType = ($groupable) ? 'groupable' : (($multiple) ? 'gallery' : 'single');
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- ?>
- <div class="field image <?=$name?>"
- data-field="<?= esc_attr($name); ?>"
- data-upload-field
- data-mode="<?= esc_attr($mode); ?>"
- <?=$dataContent?>
- <?= ' data-type="'.$dataType.'"'?>>
-
-
-
- <div class="file-upload-container">
- <div class="file-upload-wrapper">
- <input type="file"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
- accept=".jpg,.jpeg,.png,.gif,.webp"
- data-max-size="<?= $this->max_file_size; ?>"
- <?= $multiple ? 'multiple' : ''; ?>>
- <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>
- JPG, PNG, GIF, or WEBP (max. 5MB)
- </p>
- <?php if ($groupable) { ?>
- <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 } ?>
- <?php if (!empty($field['upload_description'])) : ?>
- <p><?= esc_html($field['upload_description']); ?></p>
- <?php endif; ?>
- </div>
- <div class="file-error"></div>
- </div>
- <?php if ($groupable) : ?>
- <div class="group-display" hidden>
- <div class="preview-wrap">
- <div class="preview-actions">
- <div class="selection-controls">
- <div class="selected">
- <div class="field">
- <input type="checkbox" id="select-all-uploads" name="select-all-uploads">
- <label for="select-all-uploads">
- Select All
- </label>
- </div>
- <div class="info" hidden>
- With <span class="selection-count">0</span> selected
- </div>
- </div>
-
-
- <!-- Selection actions (hidden by default) -->
- <div class="selection-actions" hidden>
- <button type="button" class="create-from-selection">
- <?= jvbIcon('add') ?>
- Create New <?= $singular ?>
- </button>
- <button type="button" class="remove-selection">
- <?= jvbIcon('delete') ?>
- Remove
- </button>
- </div>
- </div>
-
- <button type="button" class="submit-uploads">
- <?= jvbIcon('upload') ?> 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 if ($image_url) {
- echo jvbRenderImageForm((int)$value);
- } ?>
- </div>
-
- <?php if ($groupable) : ?>
- <p class="hint"><?= jvbIcon('elbow-left-up') ?> These will become individual <?= $plural ?> <?= jvbIcon('elbow-right-up')?></p>
- </div>
- <div class="sidebar">
- <div class="header">
- <h4>New <?= $plural?></h4>
- <p class="hint">Drag images into groups to create separate <?= $plural ?>.</p>
- <p class="hint">Select multiple images and click "Add to <?= $singular?>" or create new <?= $plural ?>.</p>
- </div>
- <button type="button" class="create-group-from-selection">
- <?= jvbIcon('add') ?>
- Create New <?= $singular ?>
- </button>
- <div class="item-grid groups">
- <div class="empty-group">
- <p>Drag here to create a new <?= $singular ?>!</p>
- </div>
- </div>
- <p class="hint"><?= jvbIcon('elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('elbow-right-up')?></p>
- </div>
- </div>
- <?php endif; ?>
-
- <?php if ($mode === 'direct') : ?>
- <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
- }
-
- protected function renderGalleryField(string $name, string|null|false $value, array $field):void
- {
- $ids = ($value === '' || is_null($value) || !$value) ? [] : explode(',',$value);
-
- if (!empty($ids)) {
- $ids = array_map('absint', $ids);
- }
-
- $conditional = $this->handleConditionalField($field);
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- //TODO: This can probably just be a wrapper for renderImageField...
- ?>
- <div class="field gallery <?=$name?>"
- data-field="<?= esc_attr($name); ?>"
- <?= $conditional ?>>
-
- <label><?= esc_html($field['label']); ?></label>
-
- <!-- Container for existing images -->
- <div class="gallery-preview">
- <?php
- if (!empty($ids)) {
- foreach ($ids as $id) {
- $url = wp_get_attachment_image_url($id, 'medium');
- if ($url) {
- echo '<div class="preview-item" data-id="' . esc_attr($id) . '">';
- echo '<img src="' . esc_url($url) . '" alt="">';
- echo '<button type="button" class="remove-preview">' . jvbIcon('trash', ['title'=>'Remove']) . '</button>';
- echo '</div>';
- }
- }
- }
- ?>
- </div>
-
- <!-- Hidden file uploader that will be managed by BatchFileUploader -->
- <div class="file-upload-container">
- <div class="file-upload-wrapper">
- <input type="file"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp"
- accept=".jpg,.jpeg,.png,.gif,.webp"
- multiple>
- <p class="file-upload-text">
- <strong>Click to upload</strong> or drag and drop<br>
- JPG, PNG, GIF, or WEBP (max. 5MB)
- </p>
- </div>
- <div class="file-error"></div>
- </div>
-
- <!-- Hidden input for storing the IDs -->
- <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 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 renderSelectField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $conditional = $this->handleConditionalField($field);
- $default = isset($field['default']) ? $field['default'] : '';
- $value = !empty($value) ? $value : $default;
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" data-field="<?=$name?>" <?=$conditional?>>
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
- <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
- <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
- <select
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- <?=$describedBy?>
- <?= !empty($field['required']) ? 'required' : ''; ?>
- >
- <?php foreach ($field['options'] as $key => $label) : ?>
- <option value="<?= esc_attr($key); ?>"
- <?php selected($value, $key); ?>>
- <?= esc_html($label); ?>
- </option>
- <?php endforeach; ?>
- </select>
- </div>
- <?php
- }
-
- 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();
- }
-
- echo ($content == '') ? '' : sprintf(
- '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
- esc_attr($name),
- $content
- );
- }
-
- private function renderDateField(string $name, mixed $value, array $field):void
- {
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- $conditional = $this->handleConditionalField($field);
- $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
- }
- }
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
- }
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
- <div class="date-wrapper"<?=$describedBy?>>
- <input
- type="date"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>
- data-format="<?= esc_attr($format); ?>"
- >
- <?= jvbIcon('event') ?>
- </div>
- <?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
- }
-
- public function renderTimeField(string $name, mixed $value, array $field):void
+ protected function renderHtmlField(string $name, mixed $value, array $field): void
{
- $conditional = $this->handleConditionalField($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Convert various time formats to HTML time input format (HH:MM)
- if (!empty($value)) {
- // If it's already in HH:MM format, use as-is
- if (preg_match('/^\d{2}:\d{2}$/', $value)) {
- // Value is already in correct format
- } else {
- // Try to parse and convert
- $timestamp = strtotime($value);
- if ($timestamp !== false) {
- $value = date('H:i', $timestamp);
- } else {
- $value = '';
- }
- }
+ $method_name = $field['content'];
+ $content = '';
+
+ if (method_exists($this, $method_name)) {
+ $content = $this->$method_name();
}
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
+ if ($content === '') {
+ return;
}
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
- <div class="time-wrapper"<?=$describedBy?>>
- <input
- type="time"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?>
- <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?>
- <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?>
- >
- </div>
- <?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
+ echo sprintf(
+ '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>',
+ esc_attr($name),
+ $content
+ );
}
- private function renderDatetimeField(string $name, mixed $value, array $field):void
- {
- $conditional = $this->handleConditionalField($field);
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Convert datetime to HTML datetime-local format (YYYY-MM-DDTHH:MM)
- if (!empty($value)) {
- $date = DateTime::createFromFormat('Y-m-d H:i:s', $value);
- if (!$date) {
- // Try alternative formats
- $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i'];
- foreach ($formats as $format) {
- $date = DateTime::createFromFormat($format, $value);
- if ($date) break;
- }
- }
+ /* ========== UTILITY METHODS ========== */
- if ($date) {
- $value = $date->format('Y-m-d\TH:i'); // HTML datetime-local format
- } else {
- $value = '';
- }
+ private function handleConditionalField(array $field):string
+ {
+ if (empty($field['condition'])) {
+ return '';
}
- if (array_key_exists('group', $field)) {
- $name = $field['group'].'::'.$name;
+ $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'];
}
- ?>
- <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>">
- <label for="<?= esc_attr($name); ?>">
- <?= esc_html($field['label']); ?>
- </label>
- <div class="datetime-wrapper"<?=$describedBy?>>
- <input
- type="datetime-local"
- id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
- value="<?= esc_attr($value); ?>"
- <?= !empty($field['required']) ? 'required' : ''; ?>
- <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?>
- <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?>
- <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?>
- >
- <?= jvbIcon('event') ?>
- </div>
- <?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
+
+ // Default types based on subtype
+ $defaults = [
+ 'image' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
+ 'video' => ['video/mp4', 'video/webm', 'video/ogg'],
+ 'document' => ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
+ 'any' => ['image/*', 'video/*', 'application/pdf']
+ ];
+
+ return $defaults[$config['subtype']] ?? $defaults['image'];
}
-
- public function outputCharacterCountJS():void
- {
- ?>
- <script>
- document.querySelectorAll('[maxlength]').forEach(field => {
- const counter = field.closest('.field')?.querySelector('.char-count .current');
- if (counter) {
- const updateCount = () => counter.textContent = field.value.length;
- field.addEventListener('input', updateCount);
- updateCount();
- }
- });
- </script>
- <?php
- }
-
-
- //Conditional Fields
- 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 renderDescription(string $description, string $name):void
+ protected function getMimeExtensions(array $mimeTypes): array
{
- $id = $name.'-help';
- $out = '<div class="has-tooltip">
- <span class="tt-toggle">'.jvbIcon('help').'</span>
- <div role="tooltip" id="'.$id.'"><p>'.$description.'</p></div>
- </div>';
- echo $out;
- }
-
- protected function renderHint(array|string $hint):void
- {
- if (is_array($hint)) {
- $out = '';
- foreach($hint as $h) {
- $out .= '<p class="hint">'.$h.'</p>';
+ $extensions = [];
+ foreach ($mimeTypes as $mime) {
+ if (str_contains($mime, '*')) {
+ continue; // Skip wildcards
}
- } else {
- $out = '<p class="hint">'.$hint.'</p>';
+ $ext = str_replace(['image/', 'video/', 'application/'], '', $mime);
+ $extensions[] = '.' . $ext;
}
- echo $out;
+ 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] : [];
+ }
+
}
--
Gitblit v1.10.0