| | |
| | | use DateTime; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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; |
| | | } |
| | |
| | | } |
| | | |
| | | 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; |
| | | } |
| | | $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 |
| | | } |
| | | ?> |
| | | |
| | | <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; |
| | | |
| | | // 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> |
| | | <?php |
| | | if (!$hidden) { |
| | | ?> |
| | | </fieldset> |
| | | <?php |
| | | } |
| | | } |
| | | protected function renderLocationField(string $name, mixed $value, array $field): void |
| | | { |
| | | $googleMaps = JVB()->connect('maps'); |
| | | if (!$googleMaps->isSetUp()) { |
| | | echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>'; |
| | | return; |
| | | } |
| | | |
| | | // Extract stored values |
| | | if (is_string($value)) { |
| | | $value = maybe_unserialize($value); |
| | | } |
| | | $stored_data = is_array($value) ? $value : []; |
| | | |
| | | $address = $stored_data['address'] ?? ''; |
| | | $lat = $stored_data['lat'] ?? ''; |
| | | $lng = $stored_data['lng'] ?? ''; |
| | | |
| | | // Generate unique field ID |
| | | $field_id = esc_attr($name); |
| | | $map_id = $field_id . '_map'; |
| | | |
| | | // Handle grouped fields |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | |
| | | // Prepare configuration for JavaScript initialization |
| | | $js_config = [ |
| | | 'fieldId' => $field_id, |
| | | 'initialCoords' => (!empty($lat) && !empty($lng)) ? [ |
| | | 'lat' => (float)$lat, |
| | | 'lng' => (float)$lng |
| | | ] : null |
| | | ]; |
| | | $hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden'); |
| | | |
| | | // IMPORTANT: Properly escape the JSON for use in HTML attribute |
| | | $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8'); |
| | | 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="field location <?= esc_attr($field_id) ?>" |
| | | data-field="<?= esc_attr($field_id) ?>" |
| | | data-location-field-init="<?= $json_config ?>"<?=$describedBy?>> |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | |
| | | <?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 class="group-fields <?= esc_attr($original) ?>"> |
| | | <?php $this->renderGroupFields($name, $values, $field); ?> |
| | | </div> |
| | | </div> |
| | | |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | </fieldset> |
| | | <?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); } ?> |
| | | /** |
| | | * 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; |
| | | |
| | | <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..."> |
| | | // Get the value for this specific field |
| | | $field_value = $values[$field_name] ?? ''; |
| | | |
| | | <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; ?> |
| | | // 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; |
| | | } |
| | | } |
| | | |
| | | <?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; ?> |
| | | $this->render($field_name, $field_value, $config); |
| | | } |
| | | } |
| | | |
| | | <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 |
| | | } |
| | | |
| | | /* ========== 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 = [ |
| | |
| | | <?php |
| | | } |
| | | |
| | | |
| | | protected function getAllowedTypes(array $config):array |
| | | { |
| | | $typeMap = [ |
| | | 'image' => [ |
| | | 'image/jpeg', |
| | | 'image/png', |
| | | 'image/gif', |
| | | 'image/webp' |
| | | ], |
| | | 'video' => [ |
| | | 'video/mp4', |
| | | 'video/webm', |
| | | 'video/ogg', |
| | | 'video/ogv', |
| | | 'video/quicktime' |
| | | ], |
| | | 'document' => [ |
| | | 'application/pdf', |
| | | 'application/msword', |
| | | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |
| | | 'application/vnd.ms-excel', |
| | | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
| | | 'text/plain', |
| | | 'text/csv' |
| | | ], |
| | | 'any' => [] // Will be merged from all types |
| | | ]; |
| | | // If specific types are defined, use those |
| | | if (!empty($config['accepted_types'])) { |
| | | return is_array($config['accepted_types']) |
| | | ? $config['accepted_types'] |
| | | : [$config['accepted_types']]; |
| | | } |
| | | |
| | | // Otherwise use subtype defaults |
| | | $subtype = $config['subtype'] ?? 'image'; |
| | | |
| | | if ($subtype === 'any') { |
| | | return array_merge( |
| | | $typeMap['image'], |
| | | $typeMap['video'], |
| | | $typeMap['document'] |
| | | ); |
| | | } |
| | | |
| | | return $typeMap[$subtype] ?? $typeMap['image']; |
| | | |
| | | } |
| | | /** |
| | | * Parse attachment IDs from value |
| | | */ |
| | | private function parseAttachmentIds(mixed $value): array |
| | | { |
| | | if (empty($value)) return []; |
| | | |
| | | if (is_array($value)) { |
| | | return array_filter(array_map('absint', $value)); |
| | | } |
| | | |
| | | return array_filter(array_map('absint', explode(',', $value))); |
| | | } |
| | | |
| | | /** |
| | | * Get file extensions for MIME types |
| | | */ |
| | | private function getMimeExtensions(array $mimeTypes): array |
| | | { |
| | | $extensionMap = [ |
| | | 'image/jpeg' => ['.jpg', '.jpeg'], |
| | | 'image/png' => ['.png'], |
| | | 'image/gif' => ['.gif'], |
| | | 'image/webp' => ['.webp'], |
| | | 'video/mp4' => ['.mp4'], |
| | | 'video/webm' => ['.webm'], |
| | | 'video/ogg' => ['.ogg'], |
| | | 'video/ogv' => ['.ogv'], |
| | | 'video/quicktime' => ['.mov'], |
| | | 'application/pdf' => ['.pdf'], |
| | | 'application/msword' => ['.doc'], |
| | | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ['.docx'], |
| | | 'application/vnd.ms-excel' => ['.xls'], |
| | | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['.xlsx'], |
| | | 'text/plain' => ['.txt'], |
| | | 'text/csv' => ['.csv'], |
| | | ]; |
| | | |
| | | $extensions = []; |
| | | foreach ($mimeTypes as $mime) { |
| | | if (isset($extensionMap[$mime])) { |
| | | $extensions = array_merge($extensions, $extensionMap[$mime]); |
| | | } |
| | | } |
| | | |
| | | return array_unique($extensions); |
| | | } |
| | | |
| | | /** |
| | | * Get max file size for subtype |
| | | */ |
| | |
| | | |
| | | return $sizes[$subtype] ?? $sizes['image']; |
| | | } |
| | | /** |
| | | * Get human-readable file size label |
| | | */ |
| | | private function getMaxFileSizeLabel(string $subtype): string |
| | | { |
| | | $bytes = $this->getMaxFileSize($subtype); |
| | | $mb = round($bytes / 1048576); |
| | | return "{$mb}MB"; |
| | | } |
| | | |
| | | /** |
| | | * Format file size for display |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * Render existing attachment |
| | | * Render upload preview items |
| | | */ |
| | | private function renderExistingAttachment(int $attachmentId, string $subtype): string |
| | | private function renderUploadPreviews(array $attachmentIds, array $config): void |
| | | { |
| | | $attachment = get_post($attachmentId); |
| | | if (!$attachment) return ''; |
| | | if (empty($attachmentIds)) { |
| | | return; |
| | | } |
| | | |
| | | $url = wp_get_attachment_url($attachmentId); |
| | | $thumbUrl = $subtype === 'image' |
| | | ? wp_get_attachment_image_url($attachmentId, 'medium') |
| | | : $url; |
| | | 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; |
| | | } |
| | | } |
| | | } |
| | | |
| | | ob_start(); |
| | | 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="upload-item existing" data-attachment-id="<?= esc_attr($attachmentId) ?>" data-subtype="<?= esc_attr($subtype) ?>"> |
| | | <div class="preview"> |
| | | <?php if ($subtype === 'image') : ?> |
| | | <img src="<?= esc_url($thumbUrl) ?>" alt="<?= esc_attr(get_post_meta($attachmentId, '_wp_attachment_image_alt', true)) ?>"> |
| | | <?php elseif ($subtype === 'video') : ?> |
| | | <video src="<?= esc_url($url) ?>" controls></video> |
| | | <?php else : ?> |
| | | <div class="document-preview"> |
| | | <?= jvbIcon('document') ?> |
| | | <span><?= esc_html(basename($url)) ?></span> |
| | | </div> |
| | | <?php endif; ?> |
| | | <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> |
| | | |
| | | <div class="overlay"> |
| | | <div class="actions"> |
| | | <button type="button" class="remove" title="Remove"> |
| | | <span class="screen-reader-text">Remove <?= esc_attr($subtype) ?></span> |
| | | × |
| | | <button type="button" data-action="delete-upload" title="Remove from Group"> |
| | | <?=jvbIcon('delete')?> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <details>'; |
| | | <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary> |
| | | |
| | | <?php if ($subtype === 'image') { |
| | | echo jvbImageMeta(); |
| | | } ?> |
| | | <?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 |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | 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> |
| | | 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" class="submit-uploads"> |
| | | <?= jvbIcon('upload') ?> Upload <?= esc_html($plural ?? 'Content'); ?> |
| | | <button type="button" data-action="delete-upload" title="Remove from Group"> |
| | | <?=jvbIcon('delete')?> |
| | | </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> |
| | | <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> |
| | | <p class="hint"><?= jvbIcon('elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('elbow-right-up')?></p> |
| | | </div> |
| | | </div> |
| | | <?php endif; ?> |
| | | <details>'; |
| | | <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary> |
| | | |
| | | <?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; ?> |
| | | <?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 |
| | | } |
| | | } |
| | | |
| | | protected function renderGalleryField(string $name, string|null|false $value, array $field):void |
| | | { |
| | | $ids = ($value === '' || is_null($value) || !$value) ? [] : explode(',',$value); |
| | | /** |
| | | * 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); |
| | | |
| | | if (!empty($ids)) { |
| | | $ids = array_map('absint', $ids); |
| | | } |
| | | return "{$extList} (max. {$maxSizeMB}MB)"; |
| | | } |
| | | |
| | | $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 ?>> |
| | | /* ========== TAXONOMY/USER SELECTOR FIELDS ========== */ |
| | | |
| | | <label><?= esc_html($field['label']); ?></label> |
| | | private function renderTaxonomyField(string $name, string $value, array $field): void |
| | | { |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | |
| | | <!-- 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> |
| | | $this->renderSelectorField($name, $value, $field, 'taxonomy'); |
| | | } |
| | | |
| | | <!-- 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> |
| | | private function renderUserField(string $name, string $value, array $field): void |
| | | { |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | |
| | | <!-- 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' : ''; ?>> |
| | | $this->renderSelectorField($name, $value, $field, 'post'); |
| | | } |
| | | |
| | | <?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 |
| | | /** |
| | | * 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); |
| | | $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 = ''; |
| | | } |
| | | } |
| | | } |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : ''; |
| | | |
| | | 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="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']) . '"' : ''; ?> |
| | | > |
| | | $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)); |
| | | |
| | | </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 |
| | | } |
| | | // Generate unique container ID |
| | | $containerId = $name . '-' . $type . '-selector'; |
| | | |
| | | 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; |
| | | } |
| | | // 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; |
| | | } |
| | | |
| | | if ($date) { |
| | | $value = $date->format('Y-m-d\TH:i'); // HTML datetime-local format |
| | | } else { |
| | | $value = ''; |
| | | } |
| | | } |
| | | |
| | | 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="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 |
| | | } |
| | | |
| | | |
| | | 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 |
| | | { |
| | | $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>'; |
| | | } |
| | | $selector = new TaxonomySelector($containerId, $taxonomy, $selectorConfig); |
| | | $icon = $taxonomy; |
| | | } else { |
| | | $out = '<p class="hint">'.$hint.'</p>'; |
| | | $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; |
| | | } |
| | | echo $out; |
| | | |
| | | ?> |
| | | <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'); |
| | | if (!$googleMaps->isSetUp()) { |
| | | echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>'; |
| | | return; |
| | | } |
| | | |
| | | // Extract stored values |
| | | if (is_string($value)) { |
| | | $value = maybe_unserialize($value); |
| | | } |
| | | $stored_data = is_array($value) ? $value : []; |
| | | |
| | | $address = $stored_data['address'] ?? ''; |
| | | $lat = $stored_data['lat'] ?? ''; |
| | | $lng = $stored_data['lng'] ?? ''; |
| | | |
| | | // Generate unique field ID |
| | | $field_id = esc_attr($name); |
| | | $map_id = $field_id . '_map'; |
| | | |
| | | // Handle grouped fields |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | |
| | | // Prepare configuration for JavaScript initialization |
| | | $js_config = [ |
| | | 'fieldId' => $field_id, |
| | | 'initialCoords' => (!empty($lat) && !empty($lng)) ? [ |
| | | 'lat' => (float)$lat, |
| | | 'lng' => (float)$lng |
| | | ] : null |
| | | ]; |
| | | |
| | | // IMPORTANT: Properly escape the JSON for use in HTML attribute |
| | | $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8'); |
| | | ?> |
| | | |
| | | <div class="field location <?= esc_attr($field_id) ?>" |
| | | data-field="<?= esc_attr($field_id) ?>" |
| | | data-location-field-init="<?= $json_config ?>"<?=$describedBy?>> |
| | | |
| | | <?php |
| | | if (!empty($stored_data['street'])) { |
| | | echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>'; |
| | | echo '<p class="hint"><b>Search below to change:</b></p>'; |
| | | } |
| | | ?> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | |
| | | <div class="location-search-wrapper"> |
| | | <div class="autocomplete-wrapper"></div> |
| | | |
| | | <!-- Map container --> |
| | | <div class="location-preview"> |
| | | <div id="<?= esc_attr($map_id); ?>" |
| | | class="location-map"> |
| | | </div> |
| | | |
| | | <?php if (!empty($stored_data)): |
| | | jvbLocationLinks($stored_data); |
| | | endif; ?> |
| | | </div> |
| | | |
| | | <!-- Hidden inputs for data storage --> |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]" |
| | | value="<?= esc_attr($address); ?>" |
| | | data-location-field="address"> |
| | | |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]" |
| | | value="<?= esc_attr($lat); ?>" |
| | | data-location-field="lat"> |
| | | |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]" |
| | | value="<?= esc_attr($lng); ?>" |
| | | data-location-field="lng"> |
| | | |
| | | <?php |
| | | // Component fields |
| | | $components = ['street', 'city', 'province', 'postal_code', 'country']; |
| | | foreach ($components as $component): |
| | | ?> |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]" |
| | | value="<?= esc_attr($stored_data[$component] ?? ''); ?>" |
| | | data-location-field="<?= esc_attr($component); ?>"> |
| | | <?php endforeach; ?> |
| | | |
| | | </div> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /* ========== HTML FIELD ========== */ |
| | | |
| | | protected function renderHtmlField(string $name, mixed $value, array $field): void |
| | | { |
| | | $method_name = $field['content']; |
| | | $content = ''; |
| | | |
| | | if (method_exists($this, $method_name)) { |
| | | $content = $this->$method_name(); |
| | | } |
| | | |
| | | if ($content === '') { |
| | | return; |
| | | } |
| | | |
| | | echo sprintf( |
| | | '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>', |
| | | esc_attr($name), |
| | | $content |
| | | ); |
| | | } |
| | | |
| | | /* ========== UTILITY METHODS ========== */ |
| | | |
| | | private function handleConditionalField(array $field):string |
| | | { |
| | | if (empty($field['condition'])) { |
| | | return ''; |
| | | } |
| | | |
| | | $condition = $field['condition']; |
| | | return sprintf( |
| | | 'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"', |
| | | esc_attr($field['condition']['field']), |
| | | esc_attr($field['condition']['value']), |
| | | esc_attr($field['condition']['operator'] ?? '==') |
| | | ); |
| | | } |
| | | |
| | | protected function getAllowedTypes(array $config): array |
| | | { |
| | | if (!empty($config['accepted_types'])) { |
| | | return $config['accepted_types']; |
| | | } |
| | | |
| | | // Default types based on subtype |
| | | $defaults = [ |
| | | 'image' => ['image/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']; |
| | | } |
| | | |
| | | protected function getMimeExtensions(array $mimeTypes): array |
| | | { |
| | | $extensions = []; |
| | | foreach ($mimeTypes as $mime) { |
| | | if (str_contains($mime, '*')) { |
| | | continue; // Skip wildcards |
| | | } |
| | | $ext = str_replace(['image/', 'video/', 'application/'], '', $mime); |
| | | $extensions[] = '.' . $ext; |
| | | } |
| | | return $extensions; |
| | | } |
| | | |
| | | protected function parseAttachmentIds(mixed $value): array |
| | | { |
| | | if (empty($value)) { |
| | | return []; |
| | | } |
| | | |
| | | if (is_array($value)) { |
| | | return array_filter($value, 'is_numeric'); |
| | | } |
| | | |
| | | if (is_string($value)) { |
| | | return array_filter(explode(',', $value), 'is_numeric'); |
| | | } |
| | | |
| | | return is_numeric($value) ? [$value] : []; |
| | | } |
| | | |
| | | } |