Constants
17 hours ago
Accordion.php
17 hours ago
Alert.php
17 hours ago
AttachmentCard.php
17 hours ago
Avatar.php
17 hours ago
Badge.php
17 hours ago
BaseComponent.php
17 hours ago
Button.php
17 hours ago
ConfirmationModal.php
17 hours ago
CourseFilter.php
17 hours ago
DateFilter.php
17 hours ago
DropdownFilter.php
17 hours ago
EmptyState.php
17 hours ago
FileUploader.php
17 hours ago
InputField.php
17 hours ago
Modal.php
17 hours ago
Nav.php
17 hours ago
Pagination.php
17 hours ago
Popover.php
17 hours ago
PreviewTrigger.php
17 hours ago
Progress.php
17 hours ago
SearchFilter.php
17 hours ago
Sorting.php
17 hours ago
StarRating.php
17 hours ago
StarRatingInput.php
17 hours ago
StatusSelect.php
17 hours ago
SvgIcon.php
17 hours ago
Table.php
17 hours ago
Tabs.php
17 hours ago
Tooltip.php
17 hours ago
WPEditor.php
17 hours ago
DropdownFilter.php
579 lines
| 1 | <?php |
| 2 | /** |
| 3 | * DropdownFilter Component Class. |
| 4 | * |
| 5 | * @package Tutor\Components |
| 6 | * @author Themeum |
| 7 | * @link https://themeum.com |
| 8 | * @since 4.0.0 |
| 9 | */ |
| 10 | |
| 11 | namespace Tutor\Components; |
| 12 | |
| 13 | use Tutor\Components\Constants\Size; |
| 14 | use Tutor\Components\Constants\Variant; |
| 15 | use Tutor\Components\Constants\Positions; |
| 16 | use Tutor\Components\Constants\Color; |
| 17 | use Tutor\Components\Popover; |
| 18 | use TUTOR\Icon; |
| 19 | use TUTOR\Input; |
| 20 | |
| 21 | defined( 'ABSPATH' ) || exit; |
| 22 | |
| 23 | /** |
| 24 | * Class DropdownFilter |
| 25 | * |
| 26 | * Example Usage: |
| 27 | * ``` |
| 28 | * // Basic dropdown with custom options |
| 29 | * DropdownFilter::make() |
| 30 | * ->options( $options ) |
| 31 | * ->query_param( 'status' ) |
| 32 | * ->variant( Variant::PRIMARY ) |
| 33 | * ->size( Size::SMALL ) |
| 34 | * ->search( true ) |
| 35 | * ->render(); |
| 36 | * |
| 37 | * // Course filter (convenience method) |
| 38 | * DropdownFilter::make() |
| 39 | * ->options( $courses ) |
| 40 | * ->count( $total_items ) |
| 41 | * ->query_param( 'course_id' ) |
| 42 | * ->variant( Variant::PRIMARY_SOFT ) |
| 43 | * ->size( Size::X_SMALL ) |
| 44 | * ->popover_size( Size::MEDIUM ) |
| 45 | * ->position( Positions::BOTTOM_END ) |
| 46 | * ->placeholder( __( 'Search Course', 'tutor' ) ) |
| 47 | * ->render(); |
| 48 | * ``` |
| 49 | * |
| 50 | * @since 4.0.0 |
| 51 | */ |
| 52 | class DropdownFilter extends BaseComponent { |
| 53 | |
| 54 | /** |
| 55 | * Options for the dropdown |
| 56 | * |
| 57 | * @var array |
| 58 | */ |
| 59 | protected $options = array(); |
| 60 | |
| 61 | /** |
| 62 | * Whether to show search box |
| 63 | * |
| 64 | * @var bool |
| 65 | */ |
| 66 | protected $show_search = false; |
| 67 | |
| 68 | /** |
| 69 | * Trigger button variant |
| 70 | * |
| 71 | * @var string |
| 72 | */ |
| 73 | protected $variant = Variant::LINK; |
| 74 | |
| 75 | /** |
| 76 | * Trigger button size |
| 77 | * |
| 78 | * @var string |
| 79 | */ |
| 80 | protected $size = Size::MEDIUM; |
| 81 | |
| 82 | /** |
| 83 | * Custom button CSS classes |
| 84 | * |
| 85 | * @var string |
| 86 | */ |
| 87 | protected $button_class = ''; |
| 88 | |
| 89 | /** |
| 90 | * Search placeholder |
| 91 | * |
| 92 | * @var string |
| 93 | */ |
| 94 | protected $placeholder = ''; |
| 95 | |
| 96 | /** |
| 97 | * Query parameter name to manage URL and active state |
| 98 | * |
| 99 | * @var string |
| 100 | */ |
| 101 | protected $query_param = ''; |
| 102 | |
| 103 | /** |
| 104 | * Count to display |
| 105 | * |
| 106 | * @var int|null |
| 107 | */ |
| 108 | protected $count = null; |
| 109 | |
| 110 | /** |
| 111 | * Popover size |
| 112 | * |
| 113 | * @var string |
| 114 | */ |
| 115 | protected $popover_size = Size::SMALL; |
| 116 | |
| 117 | /** |
| 118 | * On change callback function name |
| 119 | * |
| 120 | * @var string |
| 121 | */ |
| 122 | protected $on_change = ''; |
| 123 | |
| 124 | /** |
| 125 | * Alpine.js variable name for active state |
| 126 | * |
| 127 | * @var string |
| 128 | */ |
| 129 | protected $active_value = ''; |
| 130 | |
| 131 | /** |
| 132 | * Base URL for building filter links (preserves other query params when set). |
| 133 | * |
| 134 | * @var string |
| 135 | */ |
| 136 | protected $base_url = ''; |
| 137 | |
| 138 | |
| 139 | /** |
| 140 | * Icon size |
| 141 | * |
| 142 | * @var int|null |
| 143 | */ |
| 144 | protected $icon_size = null; |
| 145 | |
| 146 | /** |
| 147 | * Popover placement position |
| 148 | * |
| 149 | * @var string |
| 150 | */ |
| 151 | protected $position = Positions::BOTTOM_START; |
| 152 | |
| 153 | /** |
| 154 | * Set options list |
| 155 | * |
| 156 | * @param array $options list of options. Each item: array( 'label' => string, 'url' => string, 'count' => int|null, 'active' => bool, 'value' => mixed ). |
| 157 | * |
| 158 | * @return self |
| 159 | */ |
| 160 | public function options( array $options ): self { |
| 161 | $this->options = $options; |
| 162 | return $this; |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Set query parameter name |
| 167 | * |
| 168 | * @param string $key query parameter name. |
| 169 | * |
| 170 | * @return self |
| 171 | */ |
| 172 | public function query_param( string $key ): self { |
| 173 | $this->query_param = $key; |
| 174 | return $this; |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Set search visibility |
| 179 | * |
| 180 | * @param bool $show whether to show search. |
| 181 | * |
| 182 | * @return self |
| 183 | */ |
| 184 | public function search( bool $show ): self { |
| 185 | $this->show_search = $show; |
| 186 | return $this; |
| 187 | } |
| 188 | |
| 189 | /** |
| 190 | * Set button variant |
| 191 | * |
| 192 | * @param string $variant button variant. |
| 193 | * |
| 194 | * @return self |
| 195 | */ |
| 196 | public function variant( string $variant ): self { |
| 197 | $this->variant = $variant; |
| 198 | return $this; |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * Set button size |
| 203 | * |
| 204 | * @param string $size button size. |
| 205 | * |
| 206 | * @return self |
| 207 | */ |
| 208 | public function size( string $size ): self { |
| 209 | $this->size = $size; |
| 210 | return $this; |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Set button CSS classes |
| 215 | * |
| 216 | * @param string $classes CSS classes for the button. |
| 217 | * |
| 218 | * @return self |
| 219 | */ |
| 220 | public function button_class( string $classes ): self { |
| 221 | $this->button_class = $classes; |
| 222 | return $this; |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Set search placeholder |
| 227 | * |
| 228 | * @param string $placeholder search placeholder. |
| 229 | * |
| 230 | * @return self |
| 231 | */ |
| 232 | public function placeholder( string $placeholder ): self { |
| 233 | $this->placeholder = $placeholder; |
| 234 | return $this; |
| 235 | } |
| 236 | |
| 237 | /** |
| 238 | * Set popover width |
| 239 | * |
| 240 | * @param string $size popover width size (Size::SMALL, Size::MEDIUM, Size::LARGE). |
| 241 | * |
| 242 | * @return self |
| 243 | */ |
| 244 | public function popover_size( string $size ): self { |
| 245 | $this->popover_size = $size; |
| 246 | return $this; |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * Set icon size |
| 251 | * |
| 252 | * @param int $size icon size. |
| 253 | * |
| 254 | * @return self |
| 255 | */ |
| 256 | public function icon_size( int $size ): self { |
| 257 | $this->icon_size = $size; |
| 258 | return $this; |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Set popover placement position. |
| 263 | * |
| 264 | * @param string $position placement position (Positions::TOP, Positions::BOTTOM, etc). |
| 265 | * |
| 266 | * @return self |
| 267 | */ |
| 268 | public function position( string $position ): self { |
| 269 | $this->position = $position; |
| 270 | return $this; |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Get popover width in pixels based on size constant |
| 275 | * |
| 276 | * @param string $size Size constant. |
| 277 | * |
| 278 | * @return string Width in pixels. |
| 279 | */ |
| 280 | protected function get_popover_width( string $size ): string { |
| 281 | switch ( $size ) { |
| 282 | case Size::MEDIUM: |
| 283 | return '275px'; |
| 284 | case Size::LARGE: |
| 285 | return '350px'; |
| 286 | case Size::SMALL: |
| 287 | default: |
| 288 | return '172px'; |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * Set count |
| 294 | * |
| 295 | * @param int $count count to display. |
| 296 | * |
| 297 | * @return self |
| 298 | */ |
| 299 | public function count( int $count ): self { |
| 300 | $this->count = $count; |
| 301 | return $this; |
| 302 | } |
| 303 | |
| 304 | /** |
| 305 | * Set JS callback on change |
| 306 | * |
| 307 | * @param string $method_name JS method name. |
| 308 | * |
| 309 | * @return self |
| 310 | */ |
| 311 | public function on_change( string $method_name ): self { |
| 312 | $this->on_change = $method_name; |
| 313 | return $this; |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Set Alpine.js active state binding |
| 318 | * |
| 319 | * @param string $active_value Alpine variable name. |
| 320 | * |
| 321 | * @return self |
| 322 | */ |
| 323 | public function bind_active_value( string $active_value ): self { |
| 324 | $this->active_value = $active_value; |
| 325 | return $this; |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Set base URL for filter links so other query params are preserved (cumulative filtering). |
| 330 | * |
| 331 | * @param string $url Full current page URL including query string. |
| 332 | * |
| 333 | * @return self |
| 334 | */ |
| 335 | public function base_url( string $url ): self { |
| 336 | $this->base_url = $url; |
| 337 | return $this; |
| 338 | } |
| 339 | |
| 340 | /** |
| 341 | * Get the base URL used for auto-generated filter links. |
| 342 | * |
| 343 | * Removes transient pagination state so changing filters resets pagination. |
| 344 | * |
| 345 | * @return string |
| 346 | */ |
| 347 | protected function get_filter_base_url(): string { |
| 348 | if ( ! empty( $this->base_url ) ) { |
| 349 | return remove_query_arg( 'current_page', $this->base_url ); |
| 350 | } |
| 351 | |
| 352 | return remove_query_arg( 'current_page' ); |
| 353 | } |
| 354 | |
| 355 | |
| 356 | /** |
| 357 | * Get component content |
| 358 | * |
| 359 | * @return string |
| 360 | */ |
| 361 | public function get(): string { |
| 362 | $options = $this->options; |
| 363 | if ( empty( $options ) ) { |
| 364 | return ''; |
| 365 | } |
| 366 | |
| 367 | // Automatically manage active state and URL if query_param is set. |
| 368 | if ( ! empty( $this->query_param ) ) { |
| 369 | $current_val = Input::get( $this->query_param, '' ); |
| 370 | $base = $this->get_filter_base_url(); |
| 371 | foreach ( $options as &$option ) { |
| 372 | $val = isset( $option['value'] ) ? $option['value'] : ''; |
| 373 | if ( ! isset( $option['active'] ) ) { |
| 374 | $option['active'] = (string) $val === (string) $current_val; |
| 375 | } |
| 376 | if ( ! isset( $option['url'] ) ) { |
| 377 | if ( ! empty( $base ) ) { |
| 378 | $option['url'] = ! empty( $val ) ? add_query_arg( $this->query_param, $val, $base ) : remove_query_arg( $this->query_param, $base ); |
| 379 | } else { |
| 380 | $option['url'] = ! empty( $val ) ? add_query_arg( $this->query_param, $val ) : remove_query_arg( $this->query_param ); |
| 381 | } |
| 382 | } |
| 383 | } |
| 384 | unset( $option ); |
| 385 | } |
| 386 | |
| 387 | $active_option = null; |
| 388 | |
| 389 | foreach ( $options as $option ) { |
| 390 | if ( ! empty( $option['active'] ) ) { |
| 391 | $active_option = $option; |
| 392 | break; |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | // Fallback to first option if none is active. |
| 397 | if ( ! $active_option && ! empty( $options ) ) { |
| 398 | $active_option = $options[0]; |
| 399 | } |
| 400 | |
| 401 | $label = $active_option['label'] ?? ''; |
| 402 | $count = $active_option['count'] ?? null; |
| 403 | |
| 404 | // If count is set via count() method, use it for the active option. |
| 405 | if ( null !== $this->count ) { |
| 406 | $count = $this->count; |
| 407 | } |
| 408 | |
| 409 | $btn_class = $this->button_class; |
| 410 | if ( empty( $btn_class ) ) { |
| 411 | $size_class = 'tutor-btn-medium'; |
| 412 | if ( Size::SMALL === $this->size ) { |
| 413 | $size_class = 'tutor-btn-small'; |
| 414 | } elseif ( Size::X_SMALL === $this->size ) { |
| 415 | $size_class = 'tutor-btn-x-small'; |
| 416 | } elseif ( Size::LARGE === $this->size ) { |
| 417 | $size_class = 'tutor-btn-large'; |
| 418 | } |
| 419 | |
| 420 | if ( Variant::PRIMARY === $this->variant ) { |
| 421 | $btn_class = 'tutor-btn tutor-btn-primary ' . $size_class; |
| 422 | } elseif ( Variant::PRIMARY_SOFT === $this->variant ) { |
| 423 | $btn_class = 'tutor-btn tutor-btn-primary-soft ' . $size_class; |
| 424 | } elseif ( Variant::OUTLINE === $this->variant ) { |
| 425 | $btn_class = 'tutor-btn tutor-btn-outline ' . $size_class; |
| 426 | } else { |
| 427 | $btn_class = 'tutor-btn tutor-btn-link'; |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | $origin = Popover::TRANSFORM_ORIGIN_MAP[ $this->position ] ?? 'left.top'; |
| 432 | |
| 433 | ob_start(); |
| 434 | ?> |
| 435 | <div |
| 436 | class="tutor-dropdown-filter" |
| 437 | x-data="{ |
| 438 | ...tutorPopover({ |
| 439 | placement: '<?php echo esc_js( $this->position ); ?>', |
| 440 | offset: 4, |
| 441 | }), |
| 442 | <?php if ( $this->active_value ) : ?> |
| 443 | labels: <?php echo esc_attr( wp_json_encode( array_column( $options, 'label', 'value' ) ) ); ?>, |
| 444 | counts: <?php echo esc_attr( wp_json_encode( array_column( $options, 'count', 'value' ) ) ); ?> |
| 445 | <?php endif; ?> |
| 446 | }" |
| 447 | > |
| 448 | <button |
| 449 | type="button" |
| 450 | x-ref="trigger" |
| 451 | @click="toggle()" |
| 452 | class="<?php echo esc_attr( $btn_class ); ?>" |
| 453 | > |
| 454 | <span class="tutor-truncate <?php echo Variant::LINK === $this->variant ? esc_attr( 'tutor-text-secondary' ) : ''; ?>" style="max-width: 150px;" |
| 455 | <?php if ( $this->active_value ) : ?> |
| 456 | x-text="labels[<?php echo esc_attr( $this->active_value ); ?>] || '<?php echo esc_js( $label ); ?>'" |
| 457 | <?php endif; ?> |
| 458 | > |
| 459 | <?php echo esc_html( $label ); ?> |
| 460 | </span> |
| 461 | <?php if ( null !== $count || $this->active_value ) : ?> |
| 462 | <span class="tutor-font-medium <?php echo Variant::LINK === $this->variant ? esc_attr( 'tutor-text-primary' ) : ''; ?>" |
| 463 | <?php if ( $this->active_value ) : ?> |
| 464 | x-text="'(' + (counts[<?php echo esc_attr( $this->active_value ); ?>] ?? '0') + ')'" |
| 465 | x-show="counts[<?php echo esc_attr( $this->active_value ); ?>] !== null" |
| 466 | <?php endif; ?> |
| 467 | > |
| 468 | <?php echo null !== $count ? '(' . esc_html( $count ) . ')' : ''; ?> |
| 469 | </span> |
| 470 | <?php endif; ?> |
| 471 | <?php |
| 472 | $icon_size = $this->icon_size; |
| 473 | if ( null === $icon_size ) { |
| 474 | $icon_size = in_array( $this->variant, array( Variant::PRIMARY, Variant::PRIMARY_SOFT ), true ) ? 16 : 20; |
| 475 | } |
| 476 | SvgIcon::make()->name( Icon::CHEVRON_DOWN_2 )->size( $icon_size )->color( Variant::PRIMARY_SOFT === $this->variant ? Color::BRAND : Color::SECONDARY )->render(); |
| 477 | ?> |
| 478 | </button> |
| 479 | |
| 480 | <div |
| 481 | x-ref="content" |
| 482 | x-show="open" |
| 483 | x-cloak |
| 484 | x-transition.<?php echo esc_attr( $origin ); ?> |
| 485 | @click.outside="handleClickOutside()" |
| 486 | class="tutor-popover" |
| 487 | style="width: <?php echo esc_attr( $this->get_popover_width( $this->popover_size ) ); ?>; max-width: unset;" |
| 488 | > |
| 489 | <div class="tutor-popover-menu" x-data="{ search: '', get hasResults() { return this.search === '' || [...$el.querySelectorAll('.tutor-popover-menu-item')].some(el => el.style.display !== 'none' && !el.hasAttribute('hidden')); } }"> |
| 490 | <?php if ( $this->show_search ) : ?> |
| 491 | <div class="tutor-input-field tutor-px-5 tutor-pt-2 tutor-pb-4"> |
| 492 | <div class="tutor-input-wrapper"> |
| 493 | <div class="tutor-input-content tutor-input-content-left"> |
| 494 | <?php SvgIcon::make()->name( Icon::SEARCH_2 )->size( 16 )->color( Color::IDLE )->render(); ?> |
| 495 | </div> |
| 496 | <input |
| 497 | type="text" |
| 498 | class="tutor-input tutor-input-sm tutor-input-content-left" |
| 499 | placeholder="<?php echo esc_attr( $this->placeholder ? $this->placeholder : __( 'Search...', 'tutor' ) ); ?>" |
| 500 | x-model="search" |
| 501 | @click.stop |
| 502 | > |
| 503 | </div> |
| 504 | </div> |
| 505 | <?php endif; ?> |
| 506 | |
| 507 | <div class="tutor-overflow-y-auto" style="max-height: 200px;"> |
| 508 | <?php foreach ( $options as $option ) : ?> |
| 509 | <?php |
| 510 | $opt_label = $option['label'] ?? ''; |
| 511 | $opt_url = $option['url'] ?? '#'; |
| 512 | $opt_count = $option['count'] ?? null; |
| 513 | $is_active = ! empty( $option['active'] ); |
| 514 | $val = isset( $option['value'] ) ? $option['value'] : ''; |
| 515 | ?> |
| 516 | <?php if ( $this->on_change ) : ?> |
| 517 | <button |
| 518 | type="button" |
| 519 | @click="<?php echo esc_js( $this->on_change ); ?>('<?php echo esc_attr( $val ); ?>'); hide()" |
| 520 | class="tutor-popover-menu-item <?php echo esc_attr( ! $this->active_value && $is_active ? ' tutor-active' : '' ); ?>" |
| 521 | <?php if ( $this->active_value ) : ?> |
| 522 | :class="{ 'tutor-active': (<?php echo esc_attr( $this->active_value ); ?> || '') === '<?php echo esc_attr( $val ); ?>' }" |
| 523 | <?php endif; ?> |
| 524 | <?php if ( $this->show_search ) : ?> |
| 525 | x-show="search === '' || '<?php echo esc_js( $opt_label ); ?>'.toLowerCase().includes(search.toLowerCase())" |
| 526 | <?php endif; ?> |
| 527 | > |
| 528 | <span class="tutor-flex tutor-gap-2 tutor-items-center tutor-w-full"> |
| 529 | <span class="tutor-truncate"> |
| 530 | <?php echo esc_html( $opt_label ); ?> |
| 531 | </span> |
| 532 | <?php if ( null !== $opt_count ) : ?> |
| 533 | <span> |
| 534 | (<?php echo esc_html( $opt_count ); ?>) |
| 535 | </span> |
| 536 | <?php endif; ?> |
| 537 | </span> |
| 538 | </button> |
| 539 | <?php else : ?> |
| 540 | <a |
| 541 | href="<?php echo esc_url( $opt_url ); ?>" |
| 542 | class="tutor-popover-menu-item <?php echo $is_active ? 'tutor-active' : ''; ?>" |
| 543 | <?php if ( $this->show_search ) : ?> |
| 544 | x-show="search === '' || '<?php echo esc_js( $opt_label ); ?>'.toLowerCase().includes(search.toLowerCase())" |
| 545 | <?php endif; ?> |
| 546 | > |
| 547 | <span class="tutor-flex tutor-gap-2 tutor-items-center tutor-w-full"> |
| 548 | <span class="tutor-truncate"> |
| 549 | <?php echo esc_html( $opt_label ); ?> |
| 550 | </span> |
| 551 | <?php if ( null !== $opt_count ) : ?> |
| 552 | <span> |
| 553 | (<?php echo esc_html( $opt_count ); ?>) |
| 554 | </span> |
| 555 | <?php endif; ?> |
| 556 | </span> |
| 557 | </a> |
| 558 | <?php endif; ?> |
| 559 | <?php endforeach; ?> |
| 560 | |
| 561 | <?php if ( $this->show_search ) : ?> |
| 562 | <div |
| 563 | x-show="search !== '' && !hasResults" |
| 564 | class="tutor-p-5 tutor-text-center tutor-text-secondary tutor-p2" |
| 565 | > |
| 566 | <?php esc_html_e( 'No results found', 'tutor' ); ?> |
| 567 | </div> |
| 568 | <?php endif; ?> |
| 569 | </div> |
| 570 | </div> |
| 571 | </div> |
| 572 | </div> |
| 573 | <?php |
| 574 | $this->component_string = ob_get_clean(); |
| 575 | |
| 576 | return $this->component_string; |
| 577 | } |
| 578 | } |
| 579 |