PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 1.11.2
Subscriptions for WooCommerce with Stripe Recurring Payments v1.11.2
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 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Admin / AdminComponents.php

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

822 lines 31.0 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 // translators: Pagination: %1$s: first item, %2$s: last item, %3$s: total items
175 $info_format = '' !== $args['info_format'] ? $args['info_format'] : __( 'Showing %1$s–%2$s of %3$s', 'subscription' );
176 $info_text = sprintf(
177 $info_format,
178 number_format_i18n( $start_item ),
179 number_format_i18n( $end_item ),
180 number_format_i18n( $item_total )
181 );
182 /**
183 * Filter the paginator info text (right-hand label, e.g. "Showing 1–10 of 96").
184 *
185 * @param string $info_text Composed info text.
186 * @param int $start_item First item on the current page (1-indexed, 0 if empty).
187 * @param int $end_item Last item on the current page (1-indexed, 0 if empty).
188 * @param int $item_total Total number of items across all pages.
189 * @param string $context Caller-supplied hint from `$args['context']`.
190 */
191 $info_text = apply_filters( 'wpsubs_pager_info_text', $info_text, $start_item, $end_item, $item_total, $args['context'] );
192 }
193
194 $build_link = function ( int $page ) use ( $args ): string {
195 /**
196 * Filter the URL built for a paginator page link (server mode only).
197 *
198 * @param string $url Computed URL (may be empty).
199 * @param int $page Target page number (1-indexed).
200 * @param array $args Pager args passed to wpsubs_render_pager().
201 */
202 $url = '';
203 if ( is_callable( $args['link_url_callback'] ) ) {
204 // Caller-supplied builder takes over entirely; it must escape itself.
205 $url = (string) call_user_func( $args['link_url_callback'], $page );
206 } elseif ( $args['base_url'] ) {
207 $url = add_query_arg(
208 array(
209 'paged' => $page,
210 'per_page' => max( 1, (int) $args['per_page'] ),
211 ),
212 $args['base_url']
213 );
214 $url = esc_url( $url );
215 }
216 return apply_filters( 'wpsubs_pager_link_url', $url, $page, $args );
217 };
218
219 $render_page_btn = function ( int $p ) use ( $current, $build_link, $args ): string {
220 $is_active = $p === $current;
221 $classes = 'wpsubs-pagination__btn';
222 if ( $is_active ) {
223 $classes .= ' wpsubs-pagination__btn--active';
224 }
225 $label = (string) $p;
226 /**
227 * Filter the label rendered for a paginator page button.
228 *
229 * @param string $label Default label (the page number as a string).
230 * @param int $p Page number being rendered.
231 * @param bool $is_active Whether this is the current page.
232 */
233 $label = apply_filters( 'wpsubs_pager_page_label', $label, $p, $is_active );
234
235 if ( 'cb' === $args['link_mode'] ) {
236 return '<button type="button" class="' . esc_attr( $classes )
237 . '" data-page="' . esc_attr( (string) $p ) . '"'
238 . ( $is_active ? ' aria-current="page"' : '' )
239 . '>' . esc_html( $label ) . '</button>';
240 }
241 return '<a href="' . $build_link( $p )
242 . '" class="' . esc_attr( $classes ) . '"'
243 . ( $is_active ? ' aria-current="page"' : '' )
244 . '>' . esc_html( $label ) . '</a>';
245 };
246
247 $render_ellipsis = function (): string {
248 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--ellipsis" aria-hidden="true">…</span>';
249 };
250
251 $render_prev = function () use ( $has_prev, $current, $build_link, $args ): string {
252 $page = $current - 1;
253 $label = '&#8249;';
254 $attrs = ' aria-label="' . esc_attr__( 'Previous page', 'subscription' ) . '"';
255 if ( $has_prev ) {
256 if ( 'cb' === $args['link_mode'] ) {
257 return '<button type="button" class="wpsubs-pagination__btn" data-page="' . esc_attr( (string) $page ) . '"' . $attrs . '>' . $label . '</button>';
258 }
259 return '<a href="' . $build_link( $page ) . '" class="wpsubs-pagination__btn"' . $attrs . '>' . $label . '</a>';
260 }
261 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--disabled" aria-hidden="true">' . $label . '</span>';
262 };
263
264 $render_next = function () use ( $has_next, $current, $build_link, $args ): string {
265 $page = $current + 1;
266 $label = '&#8250;';
267 $attrs = ' aria-label="' . esc_attr__( 'Next page', 'subscription' ) . '"';
268 if ( $has_next ) {
269 if ( 'cb' === $args['link_mode'] ) {
270 return '<button type="button" class="wpsubs-pagination__btn" data-page="' . esc_attr( (string) $page ) . '"' . $attrs . '>' . $label . '</button>';
271 }
272 return '<a href="' . $build_link( $page ) . '" class="wpsubs-pagination__btn"' . $attrs . '>' . $label . '</a>';
273 }
274 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--disabled" aria-hidden="true">' . $label . '</span>';
275 };
276 ?>
277 <div class="<?php echo esc_attr( $root_classes ); ?>"
278 <?php
279 if ( $args['id'] ) :
280 ?>
281 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
282 role="navigation"
283 aria-label="<?php echo esc_attr( $args['aria_label'] ); ?>"
284 data-wpsubs-pager
285 data-current="<?php echo esc_attr( (string) $current ); ?>"
286 data-total="<?php echo esc_attr( (string) $total ); ?>"
287 data-per-page="<?php echo esc_attr( (string) $per_page ); ?>"
288 <?php
289 if ( 'cb' === $args['link_mode'] ) :
290 ?>
291 data-link-mode="cb"<?php endif; ?>
292 <?php
293 if ( $args['info_format'] ) :
294 ?>
295 data-info-format="<?php echo esc_attr( $args['info_format'] ); ?>"<?php endif; ?>
296 <?php echo $attrs_out; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from esc_attr() parts above. ?>
297 >
298 <?php if ( $show_info ) : ?>
299 <span class="wpsubs-pagination__info"><?php echo esc_html( $info_text ); ?></span>
300 <?php endif; ?>
301 <div class="wpsubs-pagination__nav">
302 <?php echo $render_prev(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output built from safe esc_*() helpers. ?>
303 <?php
304 foreach ( $page_range as $p ) :
305 if ( null === $p ) {
306 echo $render_ellipsis(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markup is fully escaped.
307 } else {
308 echo $render_page_btn( (int) $p ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markup is fully escaped.
309 }
310 endforeach;
311 ?>
312 <?php echo $render_next(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output built from safe esc_*() helpers. ?>
313 </div>
314 </div>
315 <?php
316 }
317
318
319 /**
320 * Render a WooCommerce-style multiselect field.
321 *
322 * @param array $field {
323 * Field arguments.
324 *
325 * @type string $id Required. Meta key / input ID.
326 * @type string $label Field label.
327 * @type array $options Key => Label pairs for options.
328 * @type array|string $selected Optional. Selected value(s). Array, JSON, or CSV.
329 * @type string $desc_tip Optional. Description tooltip text.
330 * @type string $description Optional. Field description text.
331 * @type string $wrapper_class Optional. Extra wrapper classes.
332 * @type string $class Optional. Extra <select> classes.
333 * @type string $name Optional. Input name. Defaults to $id.'[]'.
334 * }
335 */
336 function subscrpt_multiselect_field( $field ) {
337 $defaults = [
338 'id' => '',
339 'label' => '',
340 'options' => [],
341 'selected' => [],
342 'desc_tip' => false,
343 'description' => '',
344 'wrapper_class' => '',
345 'wrapper_style' => '',
346 'class' => 'wc-enhanced-select',
347 'style' => '',
348 'name' => '',
349 ];
350
351 $field = wp_parse_args( $field, $defaults );
352
353 if ( empty( $field['id'] ) ) {
354 return;
355 }
356
357 $id = esc_attr( $field['id'] );
358 $name = $field['name'] ? $field['name'] : $id . '[]';
359 $label = esc_html( $field['label'] );
360 $description = $field['description'];
361 $desc_tip = $field['desc_tip'];
362
363 // Normalize selected values into array.
364 $selected = [];
365 if ( is_array( $field['selected'] ) ) {
366 $selected = $field['selected'];
367 } elseif ( is_string( $field['selected'] ) && $field['selected'] !== '' ) {
368 if ( false !== strpos( $field['selected'], '[' ) ) {
369 $tmp = json_decode( $field['selected'], true );
370 $selected = is_array( $tmp ) ? $tmp : [];
371 } else {
372 $selected = array_filter( array_map( 'trim', explode( ',', $field['selected'] ) ) );
373 }
374 }
375
376 // Build <option> list.
377 $options_html = '';
378 foreach ( $field['options'] as $key => $text ) {
379 $is_selected = in_array( (string) $key, (array) $selected, true ) ? ' selected="selected"' : '';
380 $options_html .= sprintf(
381 '<option value="%s"%s>%s</option>',
382 esc_attr( $key ),
383 $is_selected,
384 esc_html( $text )
385 );
386 }
387
388 $tooltip_html = '';
389 if ( $desc_tip && $description ) {
390 $tooltip_html = wc_help_tip( $description );
391 }
392
393 $description_html = '';
394 if ( $description && ! $desc_tip ) {
395 $description_html = '<span class="description">' . wp_kses_post( $description ) . '</span>';
396 }
397
398 // ? Escaped intentionally.
399 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
400 ?>
401 <p
402 class="form-field <?php echo esc_attr( $id . '_field ' . ( $field['wrapper_class'] ) ); ?>"
403 style="<?php echo esc_attr( $field['wrapper_style'] ); ?>"
404 >
405 <label for="<?php echo esc_attr( $id ); ?>">
406 <?php echo esc_html( $label ); ?>
407 </label>
408
409 <?php echo $tooltip_html; ?>
410
411 <select
412 multiple="multiple"
413 id="<?php echo esc_attr( $id ); ?>"
414 name="<?php echo esc_attr( $name ); ?>"
415 class="<?php echo esc_attr( $field['class'] ); ?>"
416 style="<?php echo esc_attr( $field['style'] ); ?>"
417 >
418 <?php echo $options_html; ?>
419 </select>
420
421 <?php echo $description_html; ?>
422 </p>
423 <?php
424 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
425 }
426
427 /**
428 * Render a preview for pages that require WPSubscription Pro, with a blurred background image and a call-to-action overlay.
429 *
430 * @param array $args Preview arguments.
431 */
432 function subscrpt_render_page_preview( array $args = [] ) {
433 $defaults = [
434 'preview_image_url' => SUBSCRPT_ASSETS . '/images/previews/subscrpt-health-preview.png',
435 'cta_title' => __( 'Upgrade to WPSubscription Pro', 'subscription' ),
436 'cta_description' => __( 'This page requires WPSubscription Pro. Unlock advanced features, priority support, and more with WPSubscription Pro.', 'subscription' ),
437 'cta_button_text' => __( 'âš¡ Upgrade to Pro', 'subscription' ),
438 'cta_button_url' => 'https://wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=upgrade_pro',
439 ];
440
441 $args = wp_parse_args( $args, $defaults );
442
443 ob_start();
444 ?>
445 <div style="position: relative;">
446 <div style="filter:blur(4px);pointer-events:none;">
447 <div style="max-width:1240px;margin:32px auto 0 auto;">
448 <img
449 src="<?php echo esc_url( $args['preview_image_url'] ); ?>"
450 alt="<?php esc_attr_e( 'page preview', 'subscription' ); ?>"
451 style="width:100%;display:block;"
452 />
453 </div>
454 </div>
455 <div style="position:absolute;inset:0;display:flex;align-items:top;justify-content:center;padding:100px 32px 32px;">
456 <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);">
457
458 <!-- Lock icon with radial glow -->
459 <div style="position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
460 <div style="position:absolute;width:100px;height:100px;background:radial-gradient(circle,var(--wpsubs-brand-ring) 0%,transparent 70%);border-radius:50%;"></div>
461 <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;">
462 <svg width="24" height="24" fill="none" viewBox="0 0 24 24" style="stroke:var(--wpsubs-brand);" stroke-width="2" aria-hidden="true">
463 <rect x="5" y="11" width="14" height="10" rx="2"/>
464 <path stroke-linecap="round" d="M8 11V7a4 4 0 018 0v4"/>
465 </svg>
466 </div>
467 </div>
468
469 <!-- Title -->
470 <div style="font-size:22px;font-weight:700;color:#111;margin-bottom:10px;line-height:1.3;">
471 <?php echo esc_html( $args['cta_title'] ); ?>
472 </div>
473
474 <!-- Subtitle -->
475 <div style="font-size:14px;color:#6b7280;margin-bottom:20px;line-height:1.6;">
476 <?php echo esc_html( $args['cta_description'] ); ?>
477 </div>
478
479 <!-- CTA button -->
480 <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;">
481 <?php echo esc_html( $args['cta_button_text'] ); ?>
482 </a>
483 </div>
484 </div>
485 </div>
486 <?php
487 return ob_get_clean();
488 }
489
490 /**
491 * Render an Advanced Select component.
492 *
493 * Outputs a styled trigger-button + dropdown that replaces a native <select>.
494 * A hidden <input> carries the selected value for form submission.
495 * JS (admin-components.js WPSubsAdvSelect) handles open/close and selection.
496 *
497 * @param array $args {
498 * @type string $name Hidden input name attribute. Required.
499 * @type string $placeholder Trigger label when nothing is selected.
500 * @type string $value Initial hidden-input value (default: '').
501 * @type array $options Each item: {
502 * string value Value submitted on selection.
503 * string label Display text.
504 * bool danger Red destructive style.
505 * string confirm JS confirm() message before selecting.
506 * bool divider Render a divider BEFORE this item.
507 * bool disabled Non-selectable item.
508 * }
509 * @type string $align Menu alignment: 'left' (default) or 'right'.
510 * @type string $id Optional id on the root element.
511 * @type string $class Extra classes on the root element.
512 * }
513 */
514 function wpsubs_render_adv_select( array $args ): void {
515 $args = wp_parse_args(
516 $args,
517 array(
518 'name' => '',
519 'placeholder' => __( 'Select', 'subscription' ),
520 'value' => '',
521 'options' => array(),
522 'align' => 'left',
523 'id' => '',
524 'class' => '',
525 'attrs' => array(),
526 )
527 );
528
529 $root_classes = 'wpsubs-adv-select wpsubs-adv-select--' . ( 'right' === $args['align'] ? 'right' : 'left' );
530 if ( $args['class'] ) {
531 $root_classes .= ' ' . $args['class'];
532 }
533
534 // Resolve trigger label: use matching option's label when a value is already set.
535 $trigger_label = $args['placeholder'];
536 $current_value = (string) $args['value'];
537 if ( '' !== $current_value && '-1' !== $current_value ) {
538 foreach ( $args['options'] as $opt ) {
539 if ( (string) ( $opt['value'] ?? '' ) === $current_value ) {
540 $trigger_label = $opt['label'] ?? $args['placeholder'];
541 break;
542 }
543 }
544 }
545
546 $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>';
547 ?>
548 <div class="<?php echo esc_attr( $root_classes ); ?>"
549 <?php
550 if ( $args['id'] ) :
551 ?>
552 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
553 data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
554 data-default-value="<?php echo esc_attr( $args['value'] ); ?>"
555 <?php
556 foreach ( $args['attrs'] as $attr_name => $attr_value ) :
557 echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
558 endforeach;
559 ?>
560 >
561 <button type="button" class="wpsubs-adv-select__trigger" aria-haspopup="listbox" aria-expanded="false">
562 <span class="wpsubs-adv-select__label"><?php echo esc_html( $trigger_label ); ?></span>
563 <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
564 </button>
565
566 <div class="wpsubs-adv-select__menu" role="listbox">
567 <?php
568 foreach ( $args['options'] as $option ) :
569 $option = wp_parse_args(
570 $option,
571 array(
572 'value' => '',
573 'label' => '',
574 'danger' => false,
575 'confirm' => '',
576 'divider' => false,
577 'disabled' => false,
578 )
579 );
580 if ( $option['divider'] ) :
581 ?>
582 <div class="wpsubs-adv-select__divider"></div>
583 <?php
584 continue;
585 endif;
586 ?>
587 <button
588 type="button"
589 class="wpsubs-adv-select__item<?php echo $option['danger'] ? ' wpsubs-adv-select__item--danger' : ''; ?>"
590 data-value="<?php echo esc_attr( $option['value'] ); ?>"
591 <?php
592 if ( $option['confirm'] ) :
593 ?>
594 data-confirm="<?php echo esc_attr( $option['confirm'] ); ?>"<?php endif; ?>
595 <?php
596 if ( $option['disabled'] ) :
597 ?>
598 data-disabled<?php endif; ?>
599 role="option"
600 >
601 <span class="wpsubs-adv-select__item-label"><?php echo esc_html( $option['label'] ); ?></span>
602 </button>
603 <?php endforeach; ?>
604 </div>
605
606 <?php if ( $args['name'] ) : ?>
607 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $args['value'] ); ?>">
608 <?php endif; ?>
609 </div>
610 <?php
611 }
612
613 /**
614 * Render a tag/pill select input with an inline filter and filterable dropdown.
615 * Supports single and multiple selection. No external dependencies.
616 *
617 * JS: WPSubsTagSelect (admin-components.js) auto-inits elements.
618 * Event fired on root: `wpsubs:select` — detail: { value, label, selected }
619 *
620 * @param array $args {
621 * string $name Form field name (base name, without [] suffix).
622 * string $placeholder Input placeholder shown when nothing is selected.
623 * string|array $value Current value(s). Array for multiple, string for single.
624 * array $options Options: array of { value, label, disabled? }.
625 * bool $multiple Enable multi-select mode.
626 * string $id Optional root element id.
627 * string $class Extra CSS classes for the root element.
628 * array $attrs Extra HTML attributes for the root element.
629 * }
630 */
631 function wpsubs_render_tag_select( array $args ): void {
632 $args = wp_parse_args(
633 $args,
634 array(
635 'name' => '',
636 'placeholder' => __( 'Select...', 'subscription' ),
637 'value' => '',
638 'options' => array(),
639 'multiple' => false,
640 'id' => '',
641 'class' => '',
642 'attrs' => array(),
643 )
644 );
645
646 $multiple = (bool) $args['multiple'];
647 $current_value = $multiple ? (array) $args['value'] : (string) $args['value'];
648
649 if ( $multiple ) {
650 $selected_values = array_filter( array_map( 'strval', $current_value ), fn( $v ) => '' !== $v );
651 } else {
652 $selected_values = ( '' !== $current_value ) ? array( $current_value ) : array();
653 }
654
655 // Map selected values to their labels for pill rendering.
656 $selected_labels = array();
657 foreach ( $args['options'] as $opt ) {
658 $opt_val = (string) ( $opt['value'] ?? '' );
659 if ( in_array( $opt_val, $selected_values, true ) ) {
660 $selected_labels[ $opt_val ] = $opt['label'] ?? $opt_val;
661 }
662 }
663
664 $root_classes = 'wpsubs-tag-select';
665 if ( $multiple ) {
666 $root_classes .= ' wpsubs-tag-select--multi';
667 }
668 if ( $args['class'] ) {
669 $root_classes .= ' ' . $args['class'];
670 }
671
672 $has_pills = ! empty( $selected_values );
673 $placeholder = $has_pills ? '' : esc_attr( $args['placeholder'] );
674
675 $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>';
676 ?>
677 <div
678 class="<?php echo esc_attr( $root_classes ); ?>"
679 <?php
680 if ( $args['id'] ) :
681 ?>
682 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
683 data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
684 data-name="<?php echo esc_attr( $args['name'] ); ?>"
685 <?php
686 if ( $multiple ) :
687 ?>
688 data-multiple="1"<?php endif; ?>
689 <?php
690 foreach ( $args['attrs'] as $attr_name => $attr_value ) :
691 echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
692 endforeach;
693 ?>
694 >
695 <div class="wpsubs-tag-select__field">
696 <?php foreach ( $selected_labels as $val => $lbl ) : ?>
697 <span class="wpsubs-tag-select__pill" data-value="<?php echo esc_attr( $val ); ?>">
698 <span class="wpsubs-tag-select__pill-label"><?php echo esc_html( $lbl ); ?></span>
699 <button type="button" class="wpsubs-tag-select__pill-remove" aria-label="<?php esc_attr_e( 'Remove', 'subscription' ); ?>">&#x2715;</button>
700 </span>
701 <?php endforeach; ?>
702 <input
703 type="text"
704 class="wpsubs-tag-select__input"
705 placeholder="<?php echo $placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already esc_attr'd above. ?>"
706 autocomplete="off"
707 aria-label="<?php esc_attr_e( 'Filter options', 'subscription' ); ?>"
708 />
709 <span class="wpsubs-tag-select__chevron" aria-hidden="true">
710 <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
711 </span>
712 </div>
713
714 <div class="wpsubs-tag-select__dropdown">
715 <div class="wpsubs-tag-select__list" role="listbox"
716 <?php
717 if ( $multiple ) :
718 ?>
719 aria-multiselectable="true"<?php endif; ?>>
720 <?php
721 foreach ( $args['options'] as $option ) :
722 $option = wp_parse_args(
723 $option,
724 array(
725 'value' => '',
726 'label' => '',
727 'disabled' => false,
728 )
729 );
730 $opt_value = (string) $option['value'];
731 $is_selected = in_array( $opt_value, $selected_values, true );
732 ?>
733 <button
734 type="button"
735 class="wpsubs-tag-select__item"
736 data-value="<?php echo esc_attr( $opt_value ); ?>"
737 role="option"
738 aria-selected="<?php echo $is_selected ? 'true' : 'false'; ?>"
739 <?php
740 if ( $is_selected ) :
741 ?>
742 data-selected<?php endif; ?>
743 <?php
744 if ( $option['disabled'] ) :
745 ?>
746 data-disabled<?php endif; ?>
747 style="<?php echo $is_selected ? 'display:none;' : ''; ?>"
748 ><?php echo esc_html( $option['label'] ); ?></button>
749 <?php endforeach; ?>
750 </div>
751 <div class="wpsubs-tag-select__empty"><?php esc_html_e( 'No results found.', 'subscription' ); ?></div>
752 </div>
753
754 <?php if ( $args['name'] ) : ?>
755 <?php if ( $multiple ) : ?>
756 <?php if ( empty( $selected_values ) ) : ?>
757 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="" data-ts-val />
758 <?php else : ?>
759 <?php foreach ( $selected_values as $val ) : ?>
760 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="<?php echo esc_attr( $val ); ?>" data-ts-val />
761 <?php endforeach; ?>
762 <?php endif; ?>
763 <?php else : ?>
764 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $current_value ); ?>" data-ts-val />
765 <?php endif; ?>
766 <?php endif; ?>
767 </div>
768 <?php
769 }
770
771 /**
772 * Render a modal dialog (admin-components `wpsubs-modal`).
773 *
774 * Behaviour is handled by WPSubsModal (admin-components.js): open it from any
775 * control with `data-wpsubs-modal-open="<id>"`; the backdrop, header close, and
776 * footer buttons close it; Escape closes it. The dialog is hidden until opened.
777 *
778 * @param array $args Modal arguments: `id` (required, matches the opener's target),
779 * `title` (header title), `body` (pre-escaped body HTML), `footer`
780 * (pre-escaped footer HTML, optional), `class` (extra root class,
781 * optional).
782 * @return void
783 */
784 function wpsubs_render_modal( array $args ): void {
785 $id = $args['id'] ?? '';
786 if ( empty( $id ) ) {
787 return;
788 }
789
790 $title = $args['title'] ?? '';
791 $body = $args['body'] ?? '';
792 $footer = $args['footer'] ?? '';
793 $extra_class = $args['class'] ?? '';
794 ?>
795 <div class="wpsubs-modal <?php echo esc_attr( $extra_class ); ?>" id="<?php echo esc_attr( $id ); ?>" hidden>
796 <div class="wpsubs-modal__backdrop" data-wpsubs-modal-close></div>
797 <div class="wpsubs-modal__dialog" role="dialog" aria-modal="true"<?php echo $title ? ' aria-label="' . esc_attr( $title ) . '"' : ''; ?>>
798 <div class="wpsubs-modal__head">
799 <span class="wpsubs-modal__title"><?php echo esc_html( $title ); ?></span>
800 <button type="button" class="wpsubs-modal__close" data-wpsubs-modal-close aria-label="<?php esc_attr_e( 'Close', 'subscription' ); ?>">&times;</button>
801 </div>
802 <div class="wpsubs-modal__body">
803 <?php
804 // Body is pre-escaped by the caller; re-escaping would break markup.
805 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
806 echo $body;
807 ?>
808 </div>
809 <?php if ( '' !== $footer ) : ?>
810 <div class="wpsubs-modal__footer">
811 <?php
812 // Footer is pre-escaped by the caller.
813 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
814 echo $footer;
815 ?>
816 </div>
817 <?php endif; ?>
818 </div>
819 </div>
820 <?php
821 }
822