From 0113d2e9c9ff34a6ffb10707cc76d34b67a0c367 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 19 Jan 2026 16:29:41 +0000
Subject: [PATCH] =Refactored window.getTemplate into a full templating class window.jvbTemplates. Refactored CRUD.js, UploadManager.js, FormController.js, PopulateForm.js with that in mind
---
inc/meta/MetaForm.php | 1095 ++++++++++++++++++++++++++++++++++++++++++++------------
1 files changed, 850 insertions(+), 245 deletions(-)
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index 91d9980..5225f9b 100644
--- a/inc/meta/MetaForm.php
+++ b/inc/meta/MetaForm.php
@@ -15,6 +15,7 @@
class MetaForm
{
protected int $max_file_size = 5242880;
+ protected ?MetaTypeManager $type_manager = null;
/* ========== MAIN RENDER METHOD ========== */
public function return(string $name, mixed $value, array $config, bool $showHidden = false)
@@ -24,7 +25,6 @@
public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false): mixed
{
$out = '';
-
if (jvbCheck('hidden', $config) && !$showHidden) {
return $out;
}
@@ -32,6 +32,9 @@
if (!array_key_exists('type', $config)) {
return $out;
}
+ if (!$value) {
+ $value = $this->getDefaultValue($config['type']);
+ }
// Handle hidden display type
if (array_key_exists('display', $config) && $config['display'] === 'hidden') {
@@ -71,6 +74,18 @@
return $out;
}
+ public function getDefaultValue(string $type):mixed {
+ if (!$this->type_manager) {
+ $this->type_manager = new MetaTypeManager();
+ }
+ return match ($this->type_manager->getMetaType($type)) {
+ 'object', 'array' => [],
+ 'boolean' => false,
+ 'integer' => 0,
+ default => '',
+ };
+ }
+
/* ========== HELPER METHODS ========== */
/**
@@ -164,6 +179,7 @@
if (!empty($field['validation_message'])) {
$attrs['data-validation-message'] = $field['validation_message'];
}
+
$attrs['data-type'] = $field['type'];
$attrString = '';
@@ -186,10 +202,17 @@
$validationAttrs = $this->buildValidationAttributes($field);
$conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
+ $customData = '';
+ if (array_key_exists('data', $field) && !empty($field['data'])) {
+ foreach ($field['data'] as $key => $v) {
+ $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"';
+ }
+ }
?>
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>>
<?php $this->renderLabel($name, $field); ?>
@@ -201,6 +224,7 @@
name="<?= esc_attr($data['name']) ?>"
value="<?= esc_attr($data['value']) ?>"
<?= $inputAttrs ?>
+ <?= $customData?>
>
<span class="validation-icon success" hidden aria-hidden="true">
<?= jvbIcon('check-circle') ?>
@@ -217,44 +241,6 @@
<?php
}
- protected function renderComplexFieldWrapper(string $name, array $field, callable $renderContent): void
- {
- $data = $this->prepareFieldData($name, $field['value'] ?? '', $field);
- $validationAttrs = $this->buildValidationAttributes($field);
- $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
- $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
-
- // Additional data attributes for complex fields
- $dataAttrs = '';
- if (array_key_exists('data', $field) && !empty($field['data'])) {
- foreach ($field['data'] as $key => $val) {
- $dataAttrs .= ($val === '') ? ' data-' . $key : ' data-' . $key . '="' . esc_attr($val) . '"';
- }
- }
-
- ?>
- <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
- <?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
- <?= $validationAttrs ?>
- <?= $dataAttrs ?>
- <?= $describedBy ?>>
-
- <?php if (!empty($field['label']) && (!isset($field['show_label']) || $field['show_label'])) : ?>
- <h3 class="field-label"><?= esc_html($field['label']) ?></h3>
- <?php endif; ?>
-
- <?php $this->renderHintAndDescription($field, $name); ?>
-
- <div class="field-content">
- <?php $renderContent($name, $data, $field); ?>
- </div>
-
- <span class="validation-message" hidden role="alert"></span>
- </div>
- <?php
- }
-
/**
* Render field label with optional character count
*/
@@ -280,20 +266,36 @@
*/
protected function renderHintAndDescription(array $field, string $name): void
{
- if (array_key_exists('hint', $field)) {
+ if (!empty($field['hint'])) {
$this->renderHint($field['hint']);
}
- if (array_key_exists('description', $field)) {
+ 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['input_type'] ?? 'text');
+ $this->renderStandardInput($name, $value, $field, $field['subtype'] ?? 'text');
}
public function renderEmailField(string $name, mixed $value, array $field): void
@@ -365,6 +367,7 @@
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>>
<?php $this->renderLabel($name, $field); ?>
@@ -423,6 +426,7 @@
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>>
<?php $this->renderLabel($name, $field); ?>
@@ -432,7 +436,7 @@
class="decrease"
title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>"
aria-label="Decrease <?= esc_attr($field['label']) ?>">
- <?= jvbIcon('minus') ?>
+ <?= jvbIcon('minus-square') ?>
</button>
<input type="number"
@@ -451,7 +455,7 @@
class="increase"
title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>"
aria-label="Increase <?= esc_attr($field['label']) ?>">
- <?= jvbIcon('add') ?>
+ <?= jvbIcon('plus-square') ?>
</button>
</div>
@@ -473,6 +477,7 @@
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>>
<?php $this->renderLabel($name, $field); ?>
@@ -481,8 +486,7 @@
<select
id="<?= esc_attr($data['id']) ?>"
name="<?= esc_attr($data['name']) ?>"
- <?= $inputAttrs ?>
- >
+ <?= $inputAttrs ?>>
<?php foreach ($field['options'] as $key => $label) : ?>
<option value="<?= esc_attr($key) ?>" <?php selected($value, $key); ?>>
<?= esc_html($label) ?>
@@ -514,6 +518,7 @@
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>>
<fieldset>
@@ -524,15 +529,16 @@
</legend>
<?php foreach ($field['options'] as $key => $label) : ?>
- <label class="radio-option">
- <input
- type="radio"
- name="<?= esc_attr($data['name']) ?>"
- value="<?= esc_attr($key) ?>"
- <?php checked($value, $key); ?>
- <?= !empty($field['required']) ? 'required' : '' ?>
- >
- <span><?= esc_html($label) ?></span>
+ <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>
@@ -558,6 +564,7 @@
<div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>>
<fieldset>
@@ -599,6 +606,7 @@
<div class="field true-false <?= esc_attr($name) ?> row btw"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>>
<label class="toggle-switch row" <?= $describedBy ?>>
@@ -627,20 +635,33 @@
/* ========== REPEATER FIELD ========== */
- private function renderRepeaterField(string $name, mixed $value, array $field): void
+ private function renderRepeaterField(string $name, mixed $value, array $field):void
{
+ $values = is_array($value) ? $value : array();
+
+ $conditional = $this->handleConditionalField($field);
+ $row_label = isset($field['row_label']) ? $field['row_label'] : '';
+ $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item';
if (array_key_exists('group', $field)) {
- $name = $field['group'] . '::' . $name;
+ $name = $field['group'].'::'.$name;
}
-
- $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use ($value) {
- $values = is_array($value) ? $value : [];
- $rowLabel = $field['row_label'] ?? '';
- $rowTitle = $field['new_row'] ?? 'New Item';
- $addLabel = $field['add_label'] ?? 'Add Item';
-
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
+ ?>
+ <div class="field repeater <?=$name?>"
+ data-field="<?= esc_attr($name); ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ <?= $describedBy ?>
+ <?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?>
+ <?=$conditional?>>
+ <?php
+ if (!array_key_exists('label', $field)) {
+ error_log('No label for: '.print_r($name, true));
+ }
?>
- <div class="repeater-items" data-label="<?= esc_attr($rowLabel) ?>">
+ <h3><?= esc_html($field['label']); ?></h3>
+
+
+ <div class="repeater-items">
<?php
if (!empty($values)) {
foreach ($values as $index => $row) {
@@ -650,39 +671,45 @@
?>
</div>
- <template class="<?= uniqid('repeaterTemplate') ?>">
- <?php $this->renderRepeaterRow($field['fields'], [], '', $name, $rowTitle); ?>
+ <template class="<?=uniqid('repeaterRow')?>">
+ <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
</template>
- <button type="button" class="add-repeater-row button secondary">
- <?= jvbIcon('plus', ['title' => 'Add']) ?>
- <span><?= esc_html($addLabel) ?></span>
+ <button type="button" class="add-repeater-row">
+ <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
</button>
- <?php
- });
+ <?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): 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);
+ $display_number = (is_string($index)) ? $index : ($index + 1);
?>
- <div class="repeater-row" data-index="<?= esc_attr($index) ?>">
- <details <?= is_string($index) ? 'open' : '' ?>>
+ <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>
+ <span class="drag-handle"><?= jvbIcon('dots-six-vertical'); ?></span>
+ <span class="row-number">#<?= esc_html($display_number); ?></span>
+ <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span>
<button type="button" class="remove-row" title="Remove">
- <?= jvbIcon('delete', ['title' => 'Remove']) ?>
+ <?= jvbIcon('trash', ['title'=>'Remove']); ?>
</button>
</summary>
<div class="repeater-row-content">
<?php
- foreach ($fields as $slug => $field) {
- $field_name = ($base_name === '') ? $slug : sprintf('%s:%s:%s', $base_name, $index, $slug);
- $field_value = $values[$slug] ?? '';
- $this->render($field_name, $field_value, $field);
- }
+ 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>
@@ -708,9 +735,11 @@
protected function renderGroupField(string $name, mixed $value, array $field): void
{
if (!array_key_exists('fields', $field) || empty($field['fields'])) {
+ error_log('No fields to render');
return;
}
+
$values = is_array($value) ? $value : [];
$original = $name;
@@ -730,14 +759,16 @@
$conditional = $this->handleConditionalField($field);
$validationAttrs = $this->buildValidationAttributes($field);
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
-
+ $fieldset = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'details' : 'fieldset';
+ $legend = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'summary' : 'legend';
?>
- <fieldset class="field group <?= esc_attr($name) ?>"
+ <<?= $fieldset?> class="field group <?= esc_attr($name) ?>"
<?= $conditional ?>
- data-field="<?= esc_attr($name) ?>"
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>
<?= $describedBy ?>>
- <legend><?= esc_html($field['label']) ?></legend>
+ <<?=$legend?>><?= esc_html($field['label']) ?></<?=$legend?>>
<?php $this->renderHintAndDescription($field, $name); ?>
@@ -746,7 +777,7 @@
</div>
<span class="validation-message" hidden role="alert"></span>
- </fieldset>
+ </<?= $fieldset?>>
<?php
}
@@ -776,22 +807,30 @@
}
/* ========== 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
{
- // Merge with defaults
- $config = array_merge([
- 'subtype' => 'image',
- 'accepted_types' => null,
- 'multiple' => false,
- 'limit' => 0,
- 'mode' => 'direct',
- 'destination' => 'meta',
- 'max_size' => null,
- 'convert' => 'webp',
- 'quality' => 80,
+ $defaultConfig = [
+ //File Type
+ 'subtype' => 'image', // 'image', 'video', 'document', 'any'
+ 'accepted_types' => null, // null = use subtype defaults, or array of specific MIME types
+ //Upload Behaviour
+ 'multiple' => false, // Single or multiple uploads
+ 'limit' => 0, // Max number of uploads (0 = unlimited)
+ 'mode' => 'direct', // 'direct' or 'selection'
+ //Destination
+ 'destination' => 'meta', // 'meta', 'post', 'post_group'
+ //Processing Options
+ 'max_size' => null, // Override default size limits
+ 'convert' => 'webp', // Image conversion format
+ 'quality' => 90, // Conversion quality
'create_thumbnails' => true,
- ], $field);
+ ];
+ $config = array_merge($defaultConfig, $field);
// Validate destination config
if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
@@ -799,69 +838,243 @@
return;
}
- if (array_key_exists('group', $field)) {
+ // 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;
}
- // Prepare upload configuration
- $acceptedTypes = $this->getAllowedTypes($config);
- $acceptExtensions = $this->getMimeExtensions($acceptedTypes);
- $acceptAttr = implode(',', $acceptExtensions);
- $attachmentIds = $this->parseAttachmentIds($value);
- $fieldType = $config['multiple'] ? 'gallery' : 'image';
-
- // Build data attributes for uploader.js
- $uploadData = [
- 'data-subtype' => $config['subtype'],
- 'data-mode' => $config['mode'],
- 'data-destination' => $config['destination'],
- 'data-multiple' => $config['multiple'] ? 'true' : 'false',
- 'data-limit' => $config['limit'],
- 'data-convert' => $config['convert'],
- 'data-quality' => $config['quality'],
- ];
-
- if (!empty($config['content'])) {
- $uploadData['data-content'] = $config['content'];
+ // Convert data attributes to string
+ $dataAttrString = '';
+ foreach ($dataAttrs as $attr => $val) {
+ $dataAttrString .= ' ' . $attr . ($val !== '' ? '="' . esc_attr($val) . '"' : '');
}
+ ?>
+ <div class="field upload <?= esc_attr($name) ?>"
+ data-field="<?=esc_attr($name)?>"
+ data-field-type="upload"
+ <?= $dataAttrString ?>
+ <?= $conditional ?>>
- $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
- $config, $acceptAttr, $attachmentIds, $fieldType, $uploadData, $value
- ) {
- ?>
- <div class="upload-field-wrapper <?= esc_attr($fieldType) ?>"
- <?php foreach ($uploadData as $attr => $val) : ?>
- <?= $attr ?>="<?= esc_attr($val) ?>"
- <?php endforeach; ?>>
-
- <!-- Preview Area -->
- <div class="upload-preview-area">
- <?php $this->renderUploadPreviews($attachmentIds, $config); ?>
- </div>
-
- <!-- Upload Area -->
<div class="file-upload-container">
<div class="file-upload-wrapper">
<input type="file"
- name="<?= esc_attr($data['name']) ?>_temp"
+ 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) ?>"
- <?= $config['multiple'] ? 'multiple' : '' ?>>
+ 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->getUploadInstructions($config)) ?>
+ <?= 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 class="file-error"></div>
+ <?php jvbRenderProgressBar(); ?>
</div>
- <!-- Hidden input for storing the IDs -->
- <input type="hidden"
- name="<?= esc_attr($data['name']) ?>"
- value="<?= esc_attr($value) ?>"
- <?= !empty($field['required']) ? 'required' : '' ?>>
+
+ <?php if ($destination === 'post_group') : ?>
+ <div class="group-display flex col" hidden>
+ <div class="preview-wrap flex col">
+ <div class="preview-actions">
+ <div class="selection-controls">
+ <div class="selected">
+ <div class="field">
+ <input type="checkbox" id="select-all-uploads" data-select-all data-selects="item-grid" name="select-all-uploads">
+ <label for="select-all-uploads">
+ Select All
+ </label>
+ </div>
+ <div class="info" hidden>
+
+ </div>
+ </div>
+
+ <div class="selection-actions row btw" hidden>
+ <button type="button" data-action="add-to-group">
+ <?= jvbIcon('plus-square') ?>
+ Group
+ </button>
+ <button type="button" data-action="delete-upload">
+ <?= jvbIcon('trash') ?>
+ Delete
+ </button>
+ </div>
+ </div>
+
+ <button type="button" data-action="upload" class="submit-uploads">
+ <?= jvbIcon('cloud-arrow-up') ?> Upload <?= esc_html($plural ?? 'Content'); ?>
+ </button>
+ </div>
+ <?php endif; ?>
+
+ <?php jvbRenderProgressBar('<span class="text">Processing files...</span>
+ <span class="count">0/0</span>'); ?>
+ <div class="item-grid preview">
+ <?php
+ // Render existing attachments
+ foreach ($attachmentIds as $attachmentId) {
+ echo $this->renderExistingAttachment($attachmentId, $subtype);
+ }
+ ?>
+ </div>
+
+ <?php if ($destination === 'post_group') : ?>
+ <p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> These will become individual <?= $plural ?> <?= jvbIcon('arrow-elbow-right-up')?></p>
+ </div>
+ <div class="sidebar flex col">
+ <div class="header">
+ <h4>New <?= $plural?></h4>
+ <p class="hint">Drag or select multiple images into groups to create separate <?= $plural ?>.</p>
+ </div>
+ <div class="item-grid groups">
+ <div class="empty-group">
+ <p>Drag here to create a new <?= $singular ?>!</p>
+ </div>
+ </div>
+ <p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('arrow-elbow-right-up')?></p>
+ </div>
</div>
- <?php
- });
+ <?php endif; ?>
+
+ <?php if ($destination === 'meta') : ?>
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
+ value="<?= esc_attr($value); ?>"
+ <?= !empty($field['required']) ? 'required' : ''; ?>>
+ <?php endif; ?>
+ </div>
+ <?php
+ }
+
+ private function renderExistingAttachment(int $attachmentId, string $subtype): string
+ {
+ ob_start();
+
+ switch ($subtype) {
+ case 'image':
+ $this->renderImagePreview($attachmentId);
+ break;
+ case 'video':
+ $this->renderVideoPreview($attachmentId);
+ break;
+ case 'document':
+ case 'file':
+ $this->renderFilePreview($attachmentId);
+ break;
+ default:
+ $this->renderImagePreview($attachmentId);
+ break;
+ }
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Get max file size for subtype
+ */
+ private function getMaxFileSize(string $subtype): int
+ {
+ $sizes = [
+ 'image' => 5242880, // 5MB
+ 'video' => 104857600, // 100MB
+ 'document' => 10485760 // 10MB
+ ];
+
+ return $sizes[$subtype] ?? $sizes['image'];
+ }
+
+ /**
+ * Format file size for display
+ */
+ private function formatFileSize(int $bytes): string
+ {
+ if ($bytes >= 1073741824) {
+ return number_format($bytes / 1073741824, 1) . 'GB';
+ }
+ if ($bytes >= 1048576) {
+ return number_format($bytes / 1048576, 1) . 'MB';
+ }
+ if ($bytes >= 1024) {
+ return number_format($bytes / 1024, 1) . 'KB';
+ }
+ return $bytes . 'B';
+ }
+
+ /**
+ * Get accepted types label
+ */
+ private function getAcceptedTypesLabel(string $subtype, array $extensions): string
+ {
+ $labels = [
+ 'image' => 'JPG, PNG, GIF, or WEBP',
+ 'video' => 'MP4, WEBM, or MOV',
+ 'document' => 'PDF, DOC, XLS, or TXT',
+ 'any' => 'Images, Videos, or Documents'
+ ];
+
+ return $labels[$subtype] ?? strtoupper(implode(', ', array_map(function($ext) {
+ return ltrim($ext, '.');
+ }, array_slice($extensions, 0, 3))));
}
/**
@@ -874,20 +1087,245 @@
}
foreach ($attachmentIds as $id) {
- if ($config['subtype'] === 'image') {
- $url = wp_get_attachment_image_url($id, 'thumbnail');
- if ($url) {
- echo '<div class="upload-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>';
- }
+ switch ($config['subtype']) {
+ case 'image':
+ $this->renderImagePreview($id, $config);
+ break;
+ case 'video':
+ $this->renderVideoPreview($id, $config);
+ break;
+ case 'file':
+ $this->renderFilePreview($id, $config);
+ break;
}
- // Add other subtypes (video, document) as needed
}
}
+ public function renderImagePreview(?int $id = null, array $config = []):void
+ {
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', false) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $alt = ($id) ? get_post_meta($id, '_wp_attachment_image_alt',true) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ?: '<img>
+ <video></video>
+ <span></span>' ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('trash')?>
+ </button>
+ </div>
+ </div>
+ <details>
+ <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
+
+ <?php
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+
+ // Only add image_data if not already provided
+ if (!array_key_exists('image_data', $fields)) {
+ $fields['image_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
+ ]
+ ]
+ ];
+ }
+
+ $meta = new MetaManager($id);
+ foreach ($fields as $field => $config) {
+ $meta->render('form', $field, $config);
+ }
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+ public function renderVideoPreview(?int $id = null, array $config = []):void
+ {
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $description = ($id) ? get_the_content($id) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ? $attachment : '<img>
+ <video></video>
+ <span></span>'; ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('trash')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
+
+ <?php
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+ $fields = array_merge([
+ 'upload_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'Video Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => [
+ 'title' => [
+ 'type' => 'text',
+ 'label' => 'Video Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'Video Caption',
+ 'data' => $dataID
+ ],
+ 'description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'Video Description',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ], $fields);
+ $this->render('upload_data', null, $fields);
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+ public function renderFilePreview(?int $id = null, array $config = []):void
+ {
+
+ $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
+ $caption = ($id) ? wp_get_attachment_caption($id) : '';
+ $description = ($id) ? get_the_content($id) : '';
+ $title = ($id) ? get_the_title($id) : '';
+ $addID = ($id) ? '-'.$id : '';
+ $dataID = ($id) ? ['id' => $id] : '';
+ ?>
+ <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
+ <div class="preview">
+ <?php jvbRenderProgressBar('',true) ?>
+ <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
+ <label for="select-item<?=$addID?>" aria-label="Select image">
+ <?= ($attachment) ? $attachment : '<img>
+ <video></video>
+ <span></span>'; ?>
+ </label>
+ <div class="item-actions row btw">
+ <div class="radio-button">
+ <input type="radio" class="featured btn" name="featured" id="featured" hidden>
+ <label for="featured">
+ <?=jvbIcon('star')?>
+ <?=jvbIcon('star', ['style' => 'fill'])?>
+ <span class="screen-reader-text">Set as featured image</span>
+ </label>
+ </div>
+
+ <button type="button" data-action="delete-upload" title="Remove from Group">
+ <?=jvbIcon('trash')?>
+ </button>
+ </div>
+ </div>
+ <details>';
+ <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary>
+
+ <?php
+ $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
+ $fields = array_merge([
+ 'upload_data' => [
+ 'type' => 'group',
+ 'wrap' => 'details',
+ 'label' => 'File Info',
+ 'hint' => 'These will be automatically generated if left blank.',
+ 'fields' => [
+ 'title' => [
+ 'type' => 'text',
+ 'label' => 'File Title',
+ 'value' => $title,
+ 'data' => $dataID
+ ],
+ 'caption' => [
+ 'type' => 'textarea',
+ 'value' => $caption,
+ 'label' => 'File Caption',
+ 'data' => $dataID
+ ],
+ 'description' => [
+ 'type' => 'textarea',
+ 'value' => $description,
+ 'label' => 'File Description',
+ 'data' => $dataID
+ ]
+ ]
+ ]
+ ], $fields);
+ $this->render('upload_data', null, $fields);
+ ?>
+ </details>
+ </div>
+ <?php
+ }
+
/**
* Get upload instruction text based on config
*/
@@ -903,7 +1341,7 @@
/* ========== TAXONOMY/USER SELECTOR FIELDS ========== */
- private function renderTaxonomyField(string $name, mixed $value, array $field): void
+ private function renderTaxonomyField(string $name, string $value, array $field): void
{
if (array_key_exists('group', $field)) {
$name = $field['group'] . '::' . $name;
@@ -912,7 +1350,7 @@
$this->renderSelectorField($name, $value, $field, 'taxonomy');
}
- private function renderUserField(string $name, mixed $value, array $field): void
+ private function renderUserField(string $name, string $value, array $field): void
{
if (array_key_exists('group', $field)) {
$name = $field['group'] . '::' . $name;
@@ -925,61 +1363,71 @@
* 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
+ public function renderSelectorField(string $name, mixed $value, array $field, string $type): void
{
$conditional = $this->handleConditionalField($field);
$validationAttrs = $this->buildValidationAttributes($field);
$describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
// Parse selected values
+ $value = (is_array($value)) ? array_filter(array_map('absint', $value)): $value;
$selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value));
- // Create selector instance
+ // 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 = [
- 'multiple' => $field['multiple'] ?? true,
- 'placeholder' => $field['placeholder'] ?? 'Search terms...',
- 'noResults' => 'No terms found',
- 'onClose' => 'updateMetaFormTaxonomy'
+ '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,
];
- $selector = new TaxonomySelector($taxonomy, $selectorConfig);
+ 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 = [
- 'multiple' => $field['multiple'] ?? true,
- 'placeholder' => $field['placeholder'] ?? 'Search posts...',
- 'noResults' => 'No posts found',
- 'shop_id' => $field['shop_id'] ?? null,
- 'onClose' => 'updateMetaFormPost'
+ '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($postType, $selectorConfig);
+
+ $selector = new PostSelector($containerId, $postType, $selectorConfig);
$icon = $postType;
}
- $containerId = $name . '-' . $type . '-selector';
-
?>
- <div class="field <?= esc_attr($type) ?>-selector <?= esc_attr($name) ?>"
+ <div class="field selector <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
<?= $conditional ?>
data-field="<?= esc_attr($name) ?>"
+ data-field-type="selector"
+ data-type="<?=esc_attr($field['type'])?>"
<?= $validationAttrs ?>
<?= $describedBy ?>>
- <div class="field-group-header row btw">
- <label class="toggle row">
- <?= jvbIcon($icon) ?>
- <span><?= esc_html($field['label'] ?? ucfirst($type)) ?></span>
- </label>
- <button type="button"
- class="add-item-btn button secondary"
- title="Add <?= esc_attr(ucfirst($type)) ?>">
- <?= jvbIcon('add', ['title' => 'Add ' . ucfirst($type)]) ?>
- </button>
- </div>
-
- <?= $selector->render($selected, $containerId) ?>
+ <?= $selector->render($selected) ?>
<!-- Hidden input for form submission -->
<input type="hidden"
@@ -1006,7 +1454,7 @@
return;
}
- // Parse stored data
+ // Extract stored values
if (is_string($value)) {
$value = maybe_unserialize($value);
}
@@ -1015,15 +1463,18 @@
$address = $stored_data['address'] ?? '';
$lat = $stored_data['lat'] ?? '';
$lng = $stored_data['lng'] ?? '';
- $street = $stored_data['street'] ?? '';
+ // Generate unique field ID
+ $field_id = esc_attr($name);
+ $map_id = $field_id . '_map';
+
+ // Handle grouped fields
if (array_key_exists('group', $field)) {
$name = $field['group'] . '::' . $name;
}
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
- // Prepare JavaScript configuration
- $field_id = esc_attr($name);
- $map_id = $field_id . '_map';
+ // Prepare configuration for JavaScript initialization
$js_config = [
'fieldId' => $field_id,
'initialCoords' => (!empty($lat) && !empty($lng)) ? [
@@ -1032,42 +1483,68 @@
] : null
];
- $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
- $stored_data, $street, $address, $lat, $lng, $map_id, $js_config
- ) {
- ?>
- <div class="location-field-wrapper"
- data-location-field-init="<?= esc_attr(json_encode($js_config)) ?>">
+ // IMPORTANT: Properly escape the JSON for use in HTML attribute
+ $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
+ ?>
- <?php if (!empty($street)) : ?>
- <p class="current-location">
- <strong>Current location:</strong> <?= esc_html($street) ?>
- </p>
- <?php endif; ?>
+ <div class="field location <?= esc_attr($field_id) ?>"
+ data-field="<?= esc_attr($field_id) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
- <label for="<?= esc_attr($data['id']) ?>">Address</label>
- <input type="text"
- id="<?= esc_attr($data['id']) ?>"
- name="<?= esc_attr($data['name']) ?>[address]"
- value="<?= esc_attr($address) ?>"
- placeholder="Enter an address"
- class="location-search-input"
- autocomplete="off"
- <?= !empty($field['required']) ? 'required' : '' ?>>
-
- <div id="<?= esc_attr($map_id) ?>" class="location-map" style="height: 300px;"></div>
-
- <!-- Hidden fields for lat/lng -->
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[lat]" value="<?= esc_attr($lat) ?>" class="location-lat">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[lng]" value="<?= esc_attr($lng) ?>" class="location-lng">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[street]" value="<?= esc_attr($stored_data['street'] ?? '') ?>" class="location-street">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[city]" value="<?= esc_attr($stored_data['city'] ?? '') ?>" class="location-city">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[province]" value="<?= esc_attr($stored_data['province'] ?? '') ?>" class="location-province">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[postal]" value="<?= esc_attr($stored_data['postal'] ?? '') ?>" class="location-postal">
- <input type="hidden" name="<?= esc_attr($data['name']) ?>[country]" value="<?= esc_attr($stored_data['country'] ?? '') ?>" class="location-country">
- </div>
<?php
- });
+ if (!empty($stored_data['street'])) {
+ echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>';
+ echo '<p class="hint"><b>Search below to change:</b></p>';
+ }
+ ?>
+ <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?>
+ <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?>
+
+ <div class="location-search-wrapper">
+ <div class="autocomplete-wrapper"></div>
+
+ <!-- Map container -->
+ <div class="location-preview">
+ <div id="<?= esc_attr($map_id); ?>"
+ class="location-map">
+ </div>
+
+ <?php if (!empty($stored_data)):
+ jvbLocationLinks($stored_data);
+ endif; ?>
+ </div>
+
+ <!-- Hidden inputs for data storage -->
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]"
+ value="<?= esc_attr($address); ?>"
+ data-location-field="address">
+
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]"
+ value="<?= esc_attr($lat); ?>"
+ data-location-field="lat">
+
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]"
+ value="<?= esc_attr($lng); ?>"
+ data-location-field="lng">
+
+ <?php
+ // Component fields
+ $components = ['street', 'city', 'province', 'postal_code', 'country'];
+ foreach ($components as $component):
+ ?>
+ <input type="hidden"
+ name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]"
+ value="<?= esc_attr($stored_data[$component] ?? ''); ?>"
+ data-location-field="<?= esc_attr($component); ?>">
+ <?php endforeach; ?>
+
+ </div>
+ </div>
+ <?php
}
/* ========== HTML FIELD ========== */
@@ -1109,22 +1586,6 @@
);
}
- 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
- }
-
protected function getAllowedTypes(array $config): array
{
if (!empty($config['accepted_types'])) {
@@ -1171,5 +1632,149 @@
return is_numeric($value) ? [$value] : [];
}
+ /**
+ * Render tag list field - inline tag input interface
+ */
+ protected function renderTagListField(string $name, mixed $value, array $field): void
+ {
+ $values = is_array($value) ? $value : [];
+ $conditional = $this->handleConditionalField($field);
+ $validationAttrs = $this->buildValidationAttributes($field);
+ if (array_key_exists('group', $field)) {
+ $name = $field['group'] . '::' . $name;
+ }
+
+ $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
+
+ // Tag display format - defaults to first field value
+ $tagFormat = $field['tag_format'] ?? 'first_field';
+ ?>
+ <div class="field tag-list <?= esc_attr($name) ?>"
+ data-field="<?= esc_attr($name) ?>"
+ data-field-type="<?=esc_attr($field['type'])?>"
+ data-tag-format="<?= esc_attr($tagFormat) ?>"
+ <?= $describedBy ?>
+ <?= $conditional ?>
+ <?= $validationAttrs ?>>
+
+ <?php if (!empty($field['label'])): ?>
+ <h3><?= esc_html($field['label']) ?></h3>
+ <?php endif; ?>
+
+ <!-- Inline input row -->
+ <div class="tag-input-row">
+ <?php foreach ($field['fields'] as $subfield_name => $subfield_config): ?>
+ <?php
+ $subfield_config['label'] = $subfield_config['label'] ?? ucfirst($subfield_name);
+ $input_name = 'new_' . $subfield_name;
+
+ // Store required state but don't render it on the input
+ // This prevents form submission validation but allows JS validation
+
+ if (array_key_exists('required', $subfield_config)) {
+ $subfield_config['data']['required'] = true;
+ unset($subfield_config['required']); // Remove required for HTML rendering
+ }
+ $subfield_config['data']['ignore'] = true;
+
+ $this->render($input_name, '', $subfield_config, false, false);
+ ?>
+ <?php endforeach; ?>
+
+ <button type="button" class="button add-tag-item">
+ <?= jvbIcon('plus') ?> <?= $field['add_label'] ?? 'Add' ?>
+ </button>
+ </div>
+
+ <!-- Tags display -->
+ <div class="tag-items">
+ <?php foreach ($values as $index => $item_data): ?>
+ <?php $this->renderTagItem($field['fields'], $item_data, $index, $name, $tagFormat); ?>
+ <?php endforeach; ?>
+ </div>
+
+ <!-- Template for new tags -->
+ <template class="<?=uniqid('tagListItem')?>">
+ <?php $this->renderTagItem($field['fields'], [], '', $name, $tagFormat); ?>
+ </template>
+
+ <?php if (!empty($field['hint'])): ?>
+ <?php $this->renderHint($field['hint']); ?>
+ <?php endif; ?>
+
+ <?php if (!empty($field['description'])): ?>
+ <?php $this->renderDescription($field['description'], $name); ?>
+ <?php endif; ?>
+ </div>
+ <?php
+ }
+
+ /**
+ * Render individual tag item
+ */
+ protected function renderTagItem(array $fields, array $data, int|string $index, string $base_name, string $format): void
+ {
+ $tag_text = $this->getTagDisplayText($fields, $data, $format);
+ ?>
+ <div class="tag-item" data-index="<?= esc_attr($index) ?>">
+ <span class="tag-label"><?= esc_html($tag_text) ?></span>
+
+ <!-- Hidden inputs for data -->
+ <?php foreach ($fields as $field_name => $field_config): ?>
+ <?php
+ $value = $data[$field_name] ?? '';
+ $full_name = is_string($index) ? $field_name : "{$base_name}:{$index}:{$field_name}";
+ ?>
+ <input type="hidden"
+ name="<?= esc_attr($full_name) ?>"
+ value="<?= esc_attr($value) ?>"
+ data-field="<?= esc_attr($field_name) ?>"
+ data-field-type="<?=esc_attr($field_config['type'])?>" />
+ <?php endforeach; ?>
+
+ <button type="button" class="remove-tag" aria-label="Remove">
+ <?= jvbIcon('x') ?>
+ </button>
+ </div>
+ <?php
+ }
+
+ /**
+ * Get tag display text based on format
+ */
+ protected function getTagDisplayText(array $fields, array $data, string $format): string
+ {
+ if (empty($data)) {
+ return 'New Item';
+ }
+
+ switch ($format) {
+ case 'first_field':
+ // Use the first field's value
+ $first_key = array_key_first($fields);
+ return $data[$first_key] ?? 'New Item';
+
+ case 'all_fields':
+ // Show all field values separated by commas
+ $values = array_filter(array_values($data));
+ return implode(', ', $values) ?: 'New Item';
+
+ case 'custom':
+ // Custom format - would need callback
+ return 'New Item';
+
+ default:
+ // Format is a template string like "{name} ({email})"
+ if (strpos($format, '{') !== false) {
+ $text = $format;
+ foreach ($data as $key => $value) {
+ $text = str_replace('{' . $key . '}', $value, $text);
+ }
+ return $text;
+ }
+ // Use specific field name
+ return $data[$format] ?? 'New Item';
+ }
+ }
}
--
Gitblit v1.10.0