PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 1.10.7
Subscriptions for WooCommerce with Stripe Recurring Payments v1.10.7
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
subscription / includes / Admin / AdminComponents.php

AdminComponents.php in Subscriptions for WooCommerce with Stripe Recurring Payments 1.10.7, at includes/Admin/AdminComponents.php

823 lines 30.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Compute the visible page list for a paginator (current ± 1 window with
5 * ellipsis for wider gaps, page 1 and the last page always pinned).
6 *
7 * Mirrors the JS algorithm used by WPSubsPager so server- and client-side
8 * markup agree on what to render.
9 *
10 * @param int $current Current page (1-indexed). Clamped to [1, total].
11 * @param int $total Total number of pages. Floored at 1.
12 * @return array<int|null> Page numbers in display order; `null` is an ellipsis.
13 */
14 function wpsubs_pager_page_range( int $current, int $total ): array {
15 $total = max( 1, $total );
16 $first = 1;
17 $last = $total;
18 $current = max( 1, min( $total, $current ) );
19
20 // Slide a 3-number window centred on the current page, excluding the pinned
21 // first/last pages so they don't show twice.
22 $near_start = max( 2, $current - 1 );
23 $near_end = min( $last - 1, $current + 1 );
24
25 $nearby = array();
26 for ( $i = $near_start; $i <= $near_end; $i++ ) {
27 $nearby[] = $i;
28 }
29
30 // Dedupe while preserving order (first/last may already be in $nearby).
31 $parts = array();
32 $seen = array();
33 $push = function ( int $n ) use ( &$parts, &$seen ) {
34 if ( ! isset( $seen[ $n ] ) ) {
35 $seen[ $n ] = true;
36 $parts[] = $n;
37 }
38 };
39 $push( $first );
40 foreach ( $nearby as $n ) {
41 $push( $n );
42 }
43 if ( $last > $first ) {
44 $push( $last );
45 }
46
47 // Emit ellipsis for gaps > 1 between consecutive visible numbers.
48 $range = array();
49 foreach ( $parts as $j => $p ) {
50 if ( $j > 0 ) {
51 $gap = $p - $parts[ $j - 1 ];
52 if ( 2 === $gap ) {
53 $range[] = $parts[ $j - 1 ] + 1; // single hidden page — surface it
54 } elseif ( $gap > 2 ) {
55 $range[] = null; // ellipsis
56 }
57 }
58 $range[] = $p;
59 }
60 return $range;
61 }
62
63 /**
64 * Render a WPSubscription paginator footer (info text + prev / next / numbered
65 * buttons + ellipsis). Single source of truth used by the subscriptions list
66 * (server-side) and the subscription details cards (hydrated by JS).
67 *
68 * Pair with `WPSubsPager` in `assets/js/admin-components.js` for auto-init on
69 * `.wpsubs-pager[data-wpsubs-pager]` elements. Pass `link_mode => 'cb'` from
70 * the details page so buttons trigger the JS controller instead of navigating.
71 *
72 * Markup follows the BEM classes defined in `admin-components.css`:
73 * .wpsubs-pagination
74 * .wpsubs-pagination__info
75 * .wpsubs-pagination__nav
76 * .wpsubs-pagination__btn (mods: --active | --disabled | --ellipsis)
77 *
78 * @param array $args {
79 * @type int $current Current page (1-indexed). Default 1.
80 * @type int $total Total number of pages. Default 1.
81 * @type bool $info Whether to render the "Showing X–Y of Z" info
82 * text alongside the buttons. Default false (only
83 * buttons are rendered).
84 * @type int $per_page Items per page (used when $info is true).
85 * @type int $item_count Total items (defaults to total * per_page).
86 * @type string $base_url Base URL for server-side links (link_mode=url).
87 * The component appends ?paged= and ?per_page= query args.
88 * Ignored if `link_url_callback` is provided.
89 * @type callable $link_url_callback Optional callable `function( int $page ): string`
90 * that returns the URL for a given page. When supplied,
91 * it replaces the default `add_query_arg( $base_url )` builder
92 * and lets callers (e.g. Pro templates) build URLs with
93 * admin_url() and arbitrary query args. The returned URL is
94 * not re-escaped — callers must escape with esc_url() before
95 * returning.
96 * @type string $link_mode 'url' (default, server <a href>) or 'cb' (<button data-page>).
97 * @type string $info_format sprintf-style override for the info text. Default
98 * 'Showing %1$s–%2$s of %3$s'. Pass '%3$s subscriptions'
99 * etc. for richer copy.
100 * @type string $aria_label ARIA label on the root. Default 'Pagination'.
101 * @type string $class Extra classes on the root element.
102 * @type string $id Optional id on the root.
103 * @type array $attrs Extra HTML attributes (key => value) on the root.
104 * @type string $context Free-form hint passed to filters. Default ''.
105 * }
106 */
107 function wpsubs_render_pager( array $args ): void {
108 $args = wp_parse_args(
109 $args,
110 array(
111 'current' => 1,
112 'total' => 1,
113 'info' => false,
114 'per_page' => 10,
115 'item_count' => 0,
116 'base_url' => '',
117 'link_url_callback' => null,
118 'link_mode' => 'url',
119 'info_format' => '',
120 'aria_label' => __( 'Pagination', 'subscription' ),
121 'class' => '',
122 'id' => '',
123 'attrs' => array(),
124 'context' => '',
125 )
126 );
127
128 $current = max( 1, (int) $args['current'] );
129 $total = max( 1, (int) $args['total'] );
130 $per_page = max( 1, (int) $args['per_page'] );
131 $show_info = (bool) $args['info'];
132
133 // Item window for "Showing X–Y of Z" (only used when $info is true).
134 if ( $show_info ) {
135 // Callers that know the real item_count pass it; for cards where the
136 // row count isn't known at render time (Pro activities), item_count=0
137 // keeps the info text minimal ("0–0 of 0") and the JS rehydrates it
138 // once rows are visible.
139 if ( $args['item_count'] > 0 ) {
140 $item_total = (int) $args['item_count'];
141 } else {
142 $item_total = 0;
143 }
144 $start_item = $item_total > 0 ? ( ( $current - 1 ) * $per_page ) + 1 : 0;
145 $end_item = $item_total > 0 ? min( $current * $per_page, $item_total ) : 0;
146 } else {
147 $start_item = 0;
148 $end_item = 0;
149 $item_total = 0;
150 }
151
152 $root_classes = 'wpsubs-pager wpsubs-pagination';
153 if ( 'cb' !== $args['link_mode'] ) {
154 $root_classes .= ' wpsubs-pager--links';
155 }
156 if ( $args['class'] ) {
157 $root_classes .= ' ' . $args['class'];
158 }
159
160 $attrs_out = '';
161 foreach ( $args['attrs'] as $name => $value ) {
162 $attrs_out .= ' ' . esc_attr( $name ) . '="' . esc_attr( $value ) . '"';
163 }
164
165 $page_range = wpsubs_pager_page_range( $current, $total );
166 $has_prev = $current > 1;
167 $has_next = $current < $total;
168
169 // Filterable info string (only composed when $info is true). The default
170 // keeps the previous hard-coded copy so the existing POT entry stays the
171 // source of truth.
172 $info_text = '';
173 if ( $show_info ) {
174 $info_format = '' !== $args['info_format']
175 ? $args['info_format']
176 : __( 'Showing %1$s–%2$s of %3$s', 'subscription' );
177 $info_text = sprintf(
178 $info_format,
179 number_format_i18n( $start_item ),
180 number_format_i18n( $end_item ),
181 number_format_i18n( $item_total )
182 );
183 /**
184 * Filter the paginator info text (right-hand label, e.g. "Showing 1–10 of 96").
185 *
186 * @param string $info_text Composed info text.
187 * @param int $start_item First item on the current page (1-indexed, 0 if empty).
188 * @param int $end_item Last item on the current page (1-indexed, 0 if empty).
189 * @param int $item_total Total number of items across all pages.
190 * @param string $context Caller-supplied hint from `$args['context']`.
191 */
192 $info_text = apply_filters( 'wpsubs_pager_info_text', $info_text, $start_item, $end_item, $item_total, $args['context'] );
193 }
194
195 $build_link = function ( int $page ) use ( $args ): string {
196 /**
197 * Filter the URL built for a paginator page link (server mode only).
198 *
199 * @param string $url Computed URL (may be empty).
200 * @param int $page Target page number (1-indexed).
201 * @param array $args Pager args passed to wpsubs_render_pager().
202 */
203 $url = '';
204 if ( is_callable( $args['link_url_callback'] ) ) {
205 // Caller-supplied builder takes over entirely; it must escape itself.
206 $url = (string) call_user_func( $args['link_url_callback'], $page );
207 } elseif ( $args['base_url'] ) {
208 $url = add_query_arg(
209 array(
210 'paged' => $page,
211 'per_page' => max( 1, (int) $args['per_page'] ),
212 ),
213 $args['base_url']
214 );
215 $url = esc_url( $url );
216 }
217 return apply_filters( 'wpsubs_pager_link_url', $url, $page, $args );
218 };
219
220 $render_page_btn = function ( int $p ) use ( $current, $build_link, $args ): string {
221 $is_active = $p === $current;
222 $classes = 'wpsubs-pagination__btn';
223 if ( $is_active ) {
224 $classes .= ' wpsubs-pagination__btn--active';
225 }
226 $label = (string) $p;
227 /**
228 * Filter the label rendered for a paginator page button.
229 *
230 * @param string $label Default label (the page number as a string).
231 * @param int $p Page number being rendered.
232 * @param bool $is_active Whether this is the current page.
233 */
234 $label = apply_filters( 'wpsubs_pager_page_label', $label, $p, $is_active );
235
236 if ( 'cb' === $args['link_mode'] ) {
237 return '<button type="button" class="' . esc_attr( $classes )
238 . '" data-page="' . esc_attr( (string) $p ) . '"'
239 . ( $is_active ? ' aria-current="page"' : '' )
240 . '>' . esc_html( $label ) . '</button>';
241 }
242 return '<a href="' . $build_link( $p )
243 . '" class="' . esc_attr( $classes ) . '"'
244 . ( $is_active ? ' aria-current="page"' : '' )
245 . '>' . esc_html( $label ) . '</a>';
246 };
247
248 $render_ellipsis = function (): string {
249 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--ellipsis" aria-hidden="true">…</span>';
250 };
251
252 $render_prev = function () use ( $has_prev, $current, $build_link, $args ): string {
253 $page = $current - 1;
254 $label = '&#8249;';
255 $attrs = ' aria-label="' . esc_attr__( 'Previous page', 'subscription' ) . '"';
256 if ( $has_prev ) {
257 if ( 'cb' === $args['link_mode'] ) {
258 return '<button type="button" class="wpsubs-pagination__btn" data-page="' . esc_attr( (string) $page ) . '"' . $attrs . '>' . $label . '</button>';
259 }
260 return '<a href="' . $build_link( $page ) . '" class="wpsubs-pagination__btn"' . $attrs . '>' . $label . '</a>';
261 }
262 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--disabled" aria-hidden="true">' . $label . '</span>';
263 };
264
265 $render_next = function () use ( $has_next, $current, $build_link, $args ): string {
266 $page = $current + 1;
267 $label = '&#8250;';
268 $attrs = ' aria-label="' . esc_attr__( 'Next page', 'subscription' ) . '"';
269 if ( $has_next ) {
270 if ( 'cb' === $args['link_mode'] ) {
271 return '<button type="button" class="wpsubs-pagination__btn" data-page="' . esc_attr( (string) $page ) . '"' . $attrs . '>' . $label . '</button>';
272 }
273 return '<a href="' . $build_link( $page ) . '" class="wpsubs-pagination__btn"' . $attrs . '>' . $label . '</a>';
274 }
275 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--disabled" aria-hidden="true">' . $label . '</span>';
276 };
277 ?>
278 <div class="<?php echo esc_attr( $root_classes ); ?>"
279 <?php
280 if ( $args['id'] ) :
281 ?>
282 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
283 role="navigation"
284 aria-label="<?php echo esc_attr( $args['aria_label'] ); ?>"
285 data-wpsubs-pager
286 data-current="<?php echo esc_attr( (string) $current ); ?>"
287 data-total="<?php echo esc_attr( (string) $total ); ?>"
288 data-per-page="<?php echo esc_attr( (string) $per_page ); ?>"
289 <?php
290 if ( 'cb' === $args['link_mode'] ) :
291 ?>
292 data-link-mode="cb"<?php endif; ?>
293 <?php
294 if ( $args['info_format'] ) :
295 ?>
296 data-info-format="<?php echo esc_attr( $args['info_format'] ); ?>"<?php endif; ?>
297 <?php echo $attrs_out; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from esc_attr() parts above. ?>
298 >
299 <?php if ( $show_info ) : ?>
300 <span class="wpsubs-pagination__info"><?php echo esc_html( $info_text ); ?></span>
301 <?php endif; ?>
302 <div class="wpsubs-pagination__nav">
303 <?php echo $render_prev(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output built from safe esc_*() helpers. ?>
304 <?php
305 foreach ( $page_range as $p ) :
306 if ( null === $p ) {
307 echo $render_ellipsis(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markup is fully escaped.
308 } else {
309 echo $render_page_btn( (int) $p ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markup is fully escaped.
310 }
311 endforeach;
312 ?>
313 <?php echo $render_next(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output built from safe esc_*() helpers. ?>
314 </div>
315 </div>
316 <?php
317 }
318
319
320 /**
321 * Render a WooCommerce-style multiselect field.
322 *
323 * @param array $field {
324 * Field arguments.
325 *
326 * @type string $id Required. Meta key / input ID.
327 * @type string $label Field label.
328 * @type array $options Key => Label pairs for options.
329 * @type array|string $selected Optional. Selected value(s). Array, JSON, or CSV.
330 * @type string $desc_tip Optional. Description tooltip text.
331 * @type string $description Optional. Field description text.
332 * @type string $wrapper_class Optional. Extra wrapper classes.
333 * @type string $class Optional. Extra <select> classes.
334 * @type string $name Optional. Input name. Defaults to $id.'[]'.
335 * }
336 */
337 function subscrpt_multiselect_field( $field ) {
338 $defaults = [
339 'id' => '',
340 'label' => '',
341 'options' => [],
342 'selected' => [],
343 'desc_tip' => false,
344 'description' => '',
345 'wrapper_class' => '',
346 'wrapper_style' => '',
347 'class' => 'wc-enhanced-select',
348 'style' => '',
349 'name' => '',
350 ];
351
352 $field = wp_parse_args( $field, $defaults );
353
354 if ( empty( $field['id'] ) ) {
355 return;
356 }
357
358 $id = esc_attr( $field['id'] );
359 $name = $field['name'] ? $field['name'] : $id . '[]';
360 $label = esc_html( $field['label'] );
361 $description = $field['description'];
362 $desc_tip = $field['desc_tip'];
363
364 // Normalize selected values into array.
365 $selected = [];
366 if ( is_array( $field['selected'] ) ) {
367 $selected = $field['selected'];
368 } elseif ( is_string( $field['selected'] ) && $field['selected'] !== '' ) {
369 if ( false !== strpos( $field['selected'], '[' ) ) {
370 $tmp = json_decode( $field['selected'], true );
371 $selected = is_array( $tmp ) ? $tmp : [];
372 } else {
373 $selected = array_filter( array_map( 'trim', explode( ',', $field['selected'] ) ) );
374 }
375 }
376
377 // Build <option> list.
378 $options_html = '';
379 foreach ( $field['options'] as $key => $text ) {
380 $is_selected = in_array( (string) $key, (array) $selected, true ) ? ' selected="selected"' : '';
381 $options_html .= sprintf(
382 '<option value="%s"%s>%s</option>',
383 esc_attr( $key ),
384 $is_selected,
385 esc_html( $text )
386 );
387 }
388
389 $tooltip_html = '';
390 if ( $desc_tip && $description ) {
391 $tooltip_html = wc_help_tip( $description );
392 }
393
394 $description_html = '';
395 if ( $description && ! $desc_tip ) {
396 $description_html = '<span class="description">' . wp_kses_post( $description ) . '</span>';
397 }
398
399 // ? Escaped intentionally.
400 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
401 ?>
402 <p
403 class="form-field <?php echo esc_attr( $id . '_field ' . ( $field['wrapper_class'] ) ); ?>"
404 style="<?php echo esc_attr( $field['wrapper_style'] ); ?>"
405 >
406 <label for="<?php echo esc_attr( $id ); ?>">
407 <?php echo esc_html( $label ); ?>
408 </label>
409
410 <?php echo $tooltip_html; ?>
411
412 <select
413 multiple="multiple"
414 id="<?php echo esc_attr( $id ); ?>"
415 name="<?php echo esc_attr( $name ); ?>"
416 class="<?php echo esc_attr( $field['class'] ); ?>"
417 style="<?php echo esc_attr( $field['style'] ); ?>"
418 >
419 <?php echo $options_html; ?>
420 </select>
421
422 <?php echo $description_html; ?>
423 </p>
424 <?php
425 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
426 }
427
428 /**
429 * Render a preview for pages that require WPSubscription Pro, with a blurred background image and a call-to-action overlay.
430 *
431 * @param array $args Preview arguments.
432 */
433 function subscrpt_render_page_preview( array $args = [] ) {
434 $defaults = [
435 'preview_image_url' => SUBSCRPT_ASSETS . '/images/previews/subscrpt-health-preview.png',
436 'cta_title' => __( 'Upgrade to WPSubscription Pro', 'subscription' ),
437 'cta_description' => __( 'This page requires WPSubscription Pro. Unlock advanced features, priority support, and more with WPSubscription Pro.', 'subscription' ),
438 'cta_button_text' => __( 'âš¡ Upgrade to Pro', 'subscription' ),
439 'cta_button_url' => 'https://wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=upgrade_pro',
440 ];
441
442 $args = wp_parse_args( $args, $defaults );
443
444 ob_start();
445 ?>
446 <div style="position: relative;">
447 <div style="filter:blur(4px);pointer-events:none;">
448 <div style="max-width:1240px;margin:32px auto 0 auto;">
449 <img
450 src="<?php echo esc_url( $args['preview_image_url'] ); ?>"
451 alt="<?php esc_attr_e( 'page preview', 'subscription' ); ?>"
452 style="width:100%;display:block;"
453 />
454 </div>
455 </div>
456 <div style="position:absolute;inset:0;display:flex;align-items:top;justify-content:center;padding:100px 32px 32px;">
457 <div style="height:fit-content;background:#fff;border-radius:12px;padding:28px 32px;text-align:center;max-width:440px;box-shadow:0 8px 48px rgba(0,0,0,0.22);">
458
459 <!-- Lock icon with radial glow -->
460 <div style="position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
461 <div style="position:absolute;width:100px;height:100px;background:radial-gradient(circle,var(--wpsubs-brand-ring) 0%,transparent 70%);border-radius:50%;"></div>
462 <div style="position:relative;width:56px;height:56px;border:1.5px solid var(--wpsubs-brand);border-radius:14px;display:flex;align-items:center;justify-content:center;background:#fff;">
463 <svg width="24" height="24" fill="none" viewBox="0 0 24 24" style="stroke:var(--wpsubs-brand);" stroke-width="2" aria-hidden="true">
464 <rect x="5" y="11" width="14" height="10" rx="2"/>
465 <path stroke-linecap="round" d="M8 11V7a4 4 0 018 0v4"/>
466 </svg>
467 </div>
468 </div>
469
470 <!-- Title -->
471 <div style="font-size:22px;font-weight:700;color:#111;margin-bottom:10px;line-height:1.3;">
472 <?php echo esc_html( $args['cta_title'] ); ?>
473 </div>
474
475 <!-- Subtitle -->
476 <div style="font-size:14px;color:#6b7280;margin-bottom:20px;line-height:1.6;">
477 <?php echo esc_html( $args['cta_description'] ); ?>
478 </div>
479
480 <!-- CTA button -->
481 <a href="<?php echo esc_url( $args['cta_button_url'] ); ?>" target="_blank" style="display:flex;align-items:center;justify-content:center;gap:8px;background:var(--wpsubs-brand);color:#fff;font-size:15px;font-weight:600;padding:14px 28px;border-radius:8px;text-decoration:none;">
482 <?php echo esc_html( $args['cta_button_text'] ); ?>
483 </a>
484 </div>
485 </div>
486 </div>
487 <?php
488 return ob_get_clean();
489 }
490
491 /**
492 * Render an Advanced Select component.
493 *
494 * Outputs a styled trigger-button + dropdown that replaces a native <select>.
495 * A hidden <input> carries the selected value for form submission.
496 * JS (admin-components.js WPSubsAdvSelect) handles open/close and selection.
497 *
498 * @param array $args {
499 * @type string $name Hidden input name attribute. Required.
500 * @type string $placeholder Trigger label when nothing is selected.
501 * @type string $value Initial hidden-input value (default: '').
502 * @type array $options Each item: {
503 * string value Value submitted on selection.
504 * string label Display text.
505 * bool danger Red destructive style.
506 * string confirm JS confirm() message before selecting.
507 * bool divider Render a divider BEFORE this item.
508 * bool disabled Non-selectable item.
509 * }
510 * @type string $align Menu alignment: 'left' (default) or 'right'.
511 * @type string $id Optional id on the root element.
512 * @type string $class Extra classes on the root element.
513 * }
514 */
515 function wpsubs_render_adv_select( array $args ): void {
516 $args = wp_parse_args(
517 $args,
518 array(
519 'name' => '',
520 'placeholder' => __( 'Select', 'subscription' ),
521 'value' => '',
522 'options' => array(),
523 'align' => 'left',
524 'id' => '',
525 'class' => '',
526 'attrs' => array(),
527 )
528 );
529
530 $root_classes = 'wpsubs-adv-select wpsubs-adv-select--' . ( 'right' === $args['align'] ? 'right' : 'left' );
531 if ( $args['class'] ) {
532 $root_classes .= ' ' . $args['class'];
533 }
534
535 // Resolve trigger label: use matching option's label when a value is already set.
536 $trigger_label = $args['placeholder'];
537 $current_value = (string) $args['value'];
538 if ( '' !== $current_value && '-1' !== $current_value ) {
539 foreach ( $args['options'] as $opt ) {
540 if ( (string) ( $opt['value'] ?? '' ) === $current_value ) {
541 $trigger_label = $opt['label'] ?? $args['placeholder'];
542 break;
543 }
544 }
545 }
546
547 $chevron_svg = '<svg class="wpsubs-adv-select__chevron" xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg>';
548 ?>
549 <div class="<?php echo esc_attr( $root_classes ); ?>"
550 <?php
551 if ( $args['id'] ) :
552 ?>
553 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
554 data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
555 data-default-value="<?php echo esc_attr( $args['value'] ); ?>"
556 <?php
557 foreach ( $args['attrs'] as $attr_name => $attr_value ) :
558 echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
559 endforeach;
560 ?>
561 >
562 <button type="button" class="wpsubs-adv-select__trigger" aria-haspopup="listbox" aria-expanded="false">
563 <span class="wpsubs-adv-select__label"><?php echo esc_html( $trigger_label ); ?></span>
564 <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
565 </button>
566
567 <div class="wpsubs-adv-select__menu" role="listbox">
568 <?php
569 foreach ( $args['options'] as $option ) :
570 $option = wp_parse_args(
571 $option,
572 array(
573 'value' => '',
574 'label' => '',
575 'danger' => false,
576 'confirm' => '',
577 'divider' => false,
578 'disabled' => false,
579 )
580 );
581 if ( $option['divider'] ) :
582 ?>
583 <div class="wpsubs-adv-select__divider"></div>
584 <?php
585 continue;
586 endif;
587 ?>
588 <button
589 type="button"
590 class="wpsubs-adv-select__item<?php echo $option['danger'] ? ' wpsubs-adv-select__item--danger' : ''; ?>"
591 data-value="<?php echo esc_attr( $option['value'] ); ?>"
592 <?php
593 if ( $option['confirm'] ) :
594 ?>
595 data-confirm="<?php echo esc_attr( $option['confirm'] ); ?>"<?php endif; ?>
596 <?php
597 if ( $option['disabled'] ) :
598 ?>
599 data-disabled<?php endif; ?>
600 role="option"
601 >
602 <span class="wpsubs-adv-select__item-label"><?php echo esc_html( $option['label'] ); ?></span>
603 </button>
604 <?php endforeach; ?>
605 </div>
606
607 <?php if ( $args['name'] ) : ?>
608 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $args['value'] ); ?>">
609 <?php endif; ?>
610 </div>
611 <?php
612 }
613
614 /**
615 * Render a tag/pill select input with an inline filter and filterable dropdown.
616 * Supports single and multiple selection. No external dependencies.
617 *
618 * JS: WPSubsTagSelect (admin-components.js) auto-inits elements.
619 * Event fired on root: `wpsubs:select` — detail: { value, label, selected }
620 *
621 * @param array $args {
622 * string $name Form field name (base name, without [] suffix).
623 * string $placeholder Input placeholder shown when nothing is selected.
624 * string|array $value Current value(s). Array for multiple, string for single.
625 * array $options Options: array of { value, label, disabled? }.
626 * bool $multiple Enable multi-select mode.
627 * string $id Optional root element id.
628 * string $class Extra CSS classes for the root element.
629 * array $attrs Extra HTML attributes for the root element.
630 * }
631 */
632 function wpsubs_render_tag_select( array $args ): void {
633 $args = wp_parse_args(
634 $args,
635 array(
636 'name' => '',
637 'placeholder' => __( 'Select...', 'subscription' ),
638 'value' => '',
639 'options' => array(),
640 'multiple' => false,
641 'id' => '',
642 'class' => '',
643 'attrs' => array(),
644 )
645 );
646
647 $multiple = (bool) $args['multiple'];
648 $current_value = $multiple ? (array) $args['value'] : (string) $args['value'];
649
650 if ( $multiple ) {
651 $selected_values = array_filter( array_map( 'strval', $current_value ), fn( $v ) => '' !== $v );
652 } else {
653 $selected_values = ( '' !== $current_value ) ? array( $current_value ) : array();
654 }
655
656 // Map selected values to their labels for pill rendering.
657 $selected_labels = array();
658 foreach ( $args['options'] as $opt ) {
659 $opt_val = (string) ( $opt['value'] ?? '' );
660 if ( in_array( $opt_val, $selected_values, true ) ) {
661 $selected_labels[ $opt_val ] = $opt['label'] ?? $opt_val;
662 }
663 }
664
665 $root_classes = 'wpsubs-tag-select';
666 if ( $multiple ) {
667 $root_classes .= ' wpsubs-tag-select--multi';
668 }
669 if ( $args['class'] ) {
670 $root_classes .= ' ' . $args['class'];
671 }
672
673 $has_pills = ! empty( $selected_values );
674 $placeholder = $has_pills ? '' : esc_attr( $args['placeholder'] );
675
676 $chevron_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg>';
677 ?>
678 <div
679 class="<?php echo esc_attr( $root_classes ); ?>"
680 <?php
681 if ( $args['id'] ) :
682 ?>
683 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
684 data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
685 data-name="<?php echo esc_attr( $args['name'] ); ?>"
686 <?php
687 if ( $multiple ) :
688 ?>
689 data-multiple="1"<?php endif; ?>
690 <?php
691 foreach ( $args['attrs'] as $attr_name => $attr_value ) :
692 echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
693 endforeach;
694 ?>
695 >
696 <div class="wpsubs-tag-select__field">
697 <?php foreach ( $selected_labels as $val => $lbl ) : ?>
698 <span class="wpsubs-tag-select__pill" data-value="<?php echo esc_attr( $val ); ?>">
699 <span class="wpsubs-tag-select__pill-label"><?php echo esc_html( $lbl ); ?></span>
700 <button type="button" class="wpsubs-tag-select__pill-remove" aria-label="<?php esc_attr_e( 'Remove', 'subscription' ); ?>">&#x2715;</button>
701 </span>
702 <?php endforeach; ?>
703 <input
704 type="text"
705 class="wpsubs-tag-select__input"
706 placeholder="<?php echo $placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already esc_attr'd above. ?>"
707 autocomplete="off"
708 aria-label="<?php esc_attr_e( 'Filter options', 'subscription' ); ?>"
709 />
710 <span class="wpsubs-tag-select__chevron" aria-hidden="true">
711 <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
712 </span>
713 </div>
714
715 <div class="wpsubs-tag-select__dropdown">
716 <div class="wpsubs-tag-select__list" role="listbox"
717 <?php
718 if ( $multiple ) :
719 ?>
720 aria-multiselectable="true"<?php endif; ?>>
721 <?php
722 foreach ( $args['options'] as $option ) :
723 $option = wp_parse_args(
724 $option,
725 array(
726 'value' => '',
727 'label' => '',
728 'disabled' => false,
729 )
730 );
731 $opt_value = (string) $option['value'];
732 $is_selected = in_array( $opt_value, $selected_values, true );
733 ?>
734 <button
735 type="button"
736 class="wpsubs-tag-select__item"
737 data-value="<?php echo esc_attr( $opt_value ); ?>"
738 role="option"
739 aria-selected="<?php echo $is_selected ? 'true' : 'false'; ?>"
740 <?php
741 if ( $is_selected ) :
742 ?>
743 data-selected<?php endif; ?>
744 <?php
745 if ( $option['disabled'] ) :
746 ?>
747 data-disabled<?php endif; ?>
748 style="<?php echo $is_selected ? 'display:none;' : ''; ?>"
749 ><?php echo esc_html( $option['label'] ); ?></button>
750 <?php endforeach; ?>
751 </div>
752 <div class="wpsubs-tag-select__empty"><?php esc_html_e( 'No results found.', 'subscription' ); ?></div>
753 </div>
754
755 <?php if ( $args['name'] ) : ?>
756 <?php if ( $multiple ) : ?>
757 <?php if ( empty( $selected_values ) ) : ?>
758 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="" data-ts-val />
759 <?php else : ?>
760 <?php foreach ( $selected_values as $val ) : ?>
761 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="<?php echo esc_attr( $val ); ?>" data-ts-val />
762 <?php endforeach; ?>
763 <?php endif; ?>
764 <?php else : ?>
765 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $current_value ); ?>" data-ts-val />
766 <?php endif; ?>
767 <?php endif; ?>
768 </div>
769 <?php
770 }
771
772 /**
773 * Render a modal dialog (admin-components `wpsubs-modal`).
774 *
775 * Behaviour is handled by WPSubsModal (admin-components.js): open it from any
776 * control with `data-wpsubs-modal-open="<id>"`; the backdrop, header close, and
777 * footer buttons close it; Escape closes it. The dialog is hidden until opened.
778 *
779 * @param array $args Modal arguments: `id` (required, matches the opener's target),
780 * `title` (header title), `body` (pre-escaped body HTML), `footer`
781 * (pre-escaped footer HTML, optional), `class` (extra root class,
782 * optional).
783 * @return void
784 */
785 function wpsubs_render_modal( array $args ): void {
786 $id = $args['id'] ?? '';
787 if ( empty( $id ) ) {
788 return;
789 }
790
791 $title = $args['title'] ?? '';
792 $body = $args['body'] ?? '';
793 $footer = $args['footer'] ?? '';
794 $extra_class = $args['class'] ?? '';
795 ?>
796 <div class="wpsubs-modal <?php echo esc_attr( $extra_class ); ?>" id="<?php echo esc_attr( $id ); ?>" hidden>
797 <div class="wpsubs-modal__backdrop" data-wpsubs-modal-close></div>
798 <div class="wpsubs-modal__dialog" role="dialog" aria-modal="true"<?php echo $title ? ' aria-label="' . esc_attr( $title ) . '"' : ''; ?>>
799 <div class="wpsubs-modal__head">
800 <span class="wpsubs-modal__title"><?php echo esc_html( $title ); ?></span>
801 <button type="button" class="wpsubs-modal__close" data-wpsubs-modal-close aria-label="<?php esc_attr_e( 'Close', 'subscription' ); ?>">&times;</button>
802 </div>
803 <div class="wpsubs-modal__body">
804 <?php
805 // Body is pre-escaped by the caller; re-escaping would break markup.
806 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
807 echo $body;
808 ?>
809 </div>
810 <?php if ( '' !== $footer ) : ?>
811 <div class="wpsubs-modal__footer">
812 <?php
813 // Footer is pre-escaped by the caller.
814 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
815 echo $footer;
816 ?>
817 </div>
818 <?php endif; ?>
819 </div>
820 </div>
821 <?php
822 }
823