1import { Controller } from "@hotwired/stimulus"; 2 3// Connects to data-controller="checkbox-select-all" 4export default class extends Controller { 5 static targets = [ 6 "selectAll", // The "select all" checkbox that toggles all other checkboxes 7 "checkbox", // Individual checkboxes to be selected/deselected 8 "child", // Child checkboxes within a parent group (for nested hierarchies) 9 "parent", // Container element for parent-child checkbox groups 10 "actionBar", // Element shown/hidden based on selection state (e.g., bulk actions toolbar) 11 "count", // Element displaying the count of selected items 12 "total", // Element displaying the calculated total from selected amounts 13 "amount", // Elements containing numeric values to sum when their checkbox is selected 14 "pageSelectionInfo", // Shows "X of Y row(s) selected." (visible when NOT in all-pages mode) 15 "selectAllPagesPrompt", // Shows "Select all Y rows" button (when all on page selected but not all pages) 16 "allPagesSelectedInfo", // Shows "All Y row(s) selected." (when all pages are selected) 17 "allPagesInput", // Hidden input to pass all-pages selection state to server 18 ]; 19 static values = { 20 toggleKey: { type: String, default: "" }, // Keyboard shortcut to toggle focused checkbox (recommended: "x") 21 baseAmount: { type: Number, default: 0 }, // Base amount for total calculation 22 totalItems: { type: Number, default: 0 }, // Total items across all pages (for "select all pages" feature) 23 }; 24 25 connect() { 26 this.lastCheckedIndex = null; 27 this.lastCheckedState = null; 28 this.lastShiftEnd = null; // Track the endpoint of the last shift-selection 29 this.allPagesSelected = false; // Virtual "all pages" selection state 30 this.shiftHeld = false; // Track if shift key is currently held 31 this.updateSelectAllState(); 32 this.updateActionBarVisibility(); 33 this.updateTotal(); 34 35 // Bind keyboard navigation 36 this.handleKeydown = this.handleKeydown.bind(this); 37 this.element.addEventListener("keydown", this.handleKeydown); 38 39 // Bind global shift key listeners for anchor indicator 40 this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this); 41 this.handleGlobalKeyup = this.handleGlobalKeyup.bind(this); 42 this.handleWindowBlur = this.handleWindowBlur.bind(this); 43 document.addEventListener("keydown", this.handleGlobalKeydown); 44 document.addEventListener("keyup", this.handleGlobalKeyup); 45 window.addEventListener("blur", this.handleWindowBlur); 46 } 47 48 disconnect() { 49 this.element.removeEventListener("keydown", this.handleKeydown); 50 document.removeEventListener("keydown", this.handleGlobalKeydown); 51 document.removeEventListener("keyup", this.handleGlobalKeyup); 52 window.removeEventListener("blur", this.handleWindowBlur); 53 this.clearAnchorIndicator(); 54 } 55 56 // Handle global keydown to show anchor indicator when shift is pressed 57 handleGlobalKeydown(event) { 58 if (event.key === "Shift" && !this.shiftHeld) { 59 this.shiftHeld = true; 60 this.updateAnchorIndicator(); 61 } 62 } 63 64 // Handle global keyup to hide anchor indicator when shift is released 65 handleGlobalKeyup(event) { 66 if (event.key === "Shift") { 67 this.shiftHeld = false; 68 this.clearAnchorIndicator(); 69 } 70 } 71 72 // Handle window blur to clear shift state (in case shift is released while window is not focused) 73 handleWindowBlur() { 74 this.shiftHeld = false; 75 this.clearAnchorIndicator(); 76 } 77 78 // Show dashed outline on the anchor checkbox when shift is held 79 updateAnchorIndicator() { 80 this.clearAnchorIndicator(); 81 82 if (this.shiftHeld && this.lastCheckedIndex !== null) { 83 const anchorCheckbox = this.checkboxTargets[this.lastCheckedIndex]; 84 if (anchorCheckbox) { 85 anchorCheckbox.classList.add("checkbox-anchor"); 86 } 87 } 88 } 89 90 // Remove anchor indicator from all checkboxes 91 clearAnchorIndicator() { 92 this.checkboxTargets.forEach((cb) => { 93 cb.classList.remove("checkbox-anchor"); 94 }); 95 } 96 97 // Check if we have more items than visible on current page 98 get hasMultiplePages() { 99 return this.hasTotalItemsValue && this.totalItemsValue > this.checkboxTargets.length; 100 } 101 102 // Check if all checkboxes on current page are selected 103 get allOnPageSelected() { 104 const enabledCheckboxes = this.checkboxTargets.filter((cb) => !cb.disabled); 105 return enabledCheckboxes.length > 0 && enabledCheckboxes.every((cb) => cb.checked); 106 } 107 108 // Toggle all checkboxes when the select-all checkbox is clicked 109 toggleAll(event) { 110 const isChecked = event.target.checked; 111 112 // Reset "all pages" selection when manually toggling 113 this.allPagesSelected = false;
114 115 // Select ALL checkboxes including nested ones 116 this.checkboxTargets.forEach((checkbox) => { 117 if (!checkbox.disabled) { 118 checkbox.checked = isChecked; 119 checkbox.indeterminate = false; 120 } 121 }); 122 123 // Also update any parent checkboxes that aren't in the checkbox targets 124 this.element 125 .querySelectorAll('[data-checkbox-select-all-target="parent"] input[type="checkbox"]') 126 .forEach((parentCheckbox) => { 127 if (!parentCheckbox.disabled) { 128 parentCheckbox.checked = isChecked; 129 parentCheckbox.indeterminate = false; 130 } 131 }); 132 133 this.lastCheckedIndex = null; 134 this.lastCheckedState = null; 135 this.lastShiftEnd = null; 136 this.clearAnchorIndicator(); 137 this.updateSelectAllState(); 138 this.updateActionBarVisibility(); 139 this.updateTotal(); 140 } 141 142 // Select all items across ALL pages (virtual selection) 143 // This doesn't load any data, just sets a flag that can be passed to the server 144 selectAllPages() { 145 // First, ensure all checkboxes on current page are selected 146 this.checkboxTargets.forEach((checkbox) => { 147 if (!checkbox.disabled) { 148 checkbox.checked = true; 149 checkbox.indeterminate = false; 150 } 151 }); 152 153 if (this.hasSelectAllTarget) { 154 this.selectAllTarget.checked = true; 155 this.selectAllTarget.indeterminate = false; 156 } 157 158 // Set virtual "all pages" selection 159 this.allPagesSelected = true; 160 161 this.updateActionBarVisibility(); 162 this.updateTotal(); 163 } 164 165 // Clear "all pages" selection and uncheck everything 166 clearAllPages() { 167 this.allPagesSelected = false; 168 this.clearAll(); 169 } 170 171 // Clear all selections (current page only, resets all-pages state) 172 clearAll() { 173 // Reset "all pages" selection 174 this.allPagesSelected = false; 175 176 this.checkboxTargets.forEach((checkbox) => { 177 if (!checkbox.disabled) { 178 checkbox.checked = false; 179 checkbox.indeterminate = false; 180 } 181 }); 182 183 // Clear parent checkboxes too 184 this.element 185 .querySelectorAll('[data-checkbox-select-all-target="parent"] input[type="checkbox"]') 186 .forEach((parentCheckbox) => { 187 if (!parentCheckbox.disabled) { 188 parentCheckbox.checked = false; 189 parentCheckbox.indeterminate = false; 190 } 191 }); 192 193 if (this.hasSelectAllTarget) { 194 this.selectAllTarget.checked = false; 195 this.selectAllTarget.indeterminate = false; 196 } 197 198 this.lastCheckedIndex = null; 199 this.lastCheckedState = null; 200 this.lastShiftEnd = null; 201 this.clearAnchorIndicator(); 202 this.updateActionBarVisibility(); 203 this.updateTotal(); 204 } 205 206 // Handle individual checkbox clicks 207 toggle(event) { 208 const checkbox = event.target; 209 const currentIndex = this.checkboxTargets.indexOf(checkbox); 210 211 // If the clicked element isn't one of our targets, ignore 212 if (currentIndex === -1) return; 213 214 // Reset "all pages" selection when manually toggling individual items 215 this.allPagesSelected = false; 216 217 // Handle shift-click for batch selection 218 if (event.shiftKey && this.lastCheckedIndex !== null) { 219 const anchor = this.lastCheckedIndex; 220 const targetState = this.lastCheckedState; 221 222 const newStart = Math.min(anchor, currentIndex); 223 const newEnd = Math.max(anchor, currentIndex); 224 225 // If we had a previous shift selection, handle items outside the new range 226 if (this.lastShiftEnd !== null) { 227 const prevStart = Math.min(anchor, this.lastShiftEnd); 228 const prevEnd = Math.max(anchor, this.lastShiftEnd); 229 230 // Deselect items that were in the previous range but not in the new range 231 this.checkboxTargets.forEach((cb, index) => { 232 if (!cb.disabled) { 233 const wasInPrevRange = index >= prevStart && index <= prevEnd;
234 const isInNewRange = index >= newStart && index <= newEnd; 235 236 if (wasInPrevRange && !isInNewRange) { 237 // This item was selected by previous shift-click but is outside new range 238 cb.checked = !targetState; 239 cb.indeterminate = false; 240 } 241 } 242 }); 243 } 244 245 // Apply the target state to all items in the new range 246 this.checkboxTargets.forEach((cb, index) => { 247 if (!cb.disabled && index >= newStart && index <= newEnd) { 248 cb.checked = targetState; 249 cb.indeterminate = false; 250 } 251 }); 252 253 // Remember this shift end for future shift-clicks 254 this.lastShiftEnd = currentIndex; 255 256 // We do NOT update lastCheckedIndex on shift-click, preserving the original anchor 257 } else { 258 // Normal click - this becomes the new anchor 259 this.lastCheckedIndex = currentIndex; 260 this.lastCheckedState = checkbox.checked; 261 this.lastShiftEnd = null; // Reset shift selection tracking 262 this.updateAnchorIndicator(); // Update visual indicator for new anchor 263 } 264 265 this.updateAllParentStates(); 266 this.updateSelectAllState(); 267 this.updateActionBarVisibility(); 268 this.updateTotal(); 269 } 270 271 // Update the select-all checkbox state based on individual checkboxes 272 updateSelectAllState() { 273 if (!this.hasSelectAllTarget) return; 274 275 // Get all leaf checkboxes (checkboxes that don't have children) 276 // These are checkboxes that are NOT parent checkboxes 277 const leafCheckboxes = this.checkboxTargets.filter((cb) => { 278 if (cb.disabled) return false; 279 // Check if this checkbox is a parent (has a parent container with children) 280 const parentContainer = cb.closest('[data-checkbox-select-all-target="parent"]'); 281 if (!parentContainer) return true; // Not in a parent container, so it's a leaf 282 283 // If it's in a parent container, check if it's the parent checkbox itself 284 const parentCheckbox = parentContainer.querySelector('input[type="checkbox"]'); 285 return cb !== parentCheckbox; // Only include if it's not the parent checkbox 286 }); 287 288 const checkedCount = leafCheckboxes.filter((cb) => cb.checked).length; 289 290 if (checkedCount === 0) { 291 this.selectAllTarget.checked = false; 292 this.selectAllTarget.indeterminate = false; 293 } else if (checkedCount === leafCheckboxes.length) { 294 this.selectAllTarget.checked = true; 295 this.selectAllTarget.indeterminate = false; 296 } else { 297 this.selectAllTarget.checked = false; 298 this.selectAllTarget.indeterminate = true; 299 } 300 } 301 302 // Update visibility of action bar based on selection state 303 updateActionBarVisibility() { 304 if (!this.hasActionBarTarget) return; 305 306 const checkedCount = this.checkboxTargets.filter((cb) => cb.checked && !cb.disabled).length; 307 const hasSelection = checkedCount > 0 || this.allPagesSelected; 308 309 // Show/hide action bar 310 this.actionBarTargets.forEach((actionBar) => { 311 actionBar.hidden = !hasSelection; 312 }); 313 314 // Update "select all pages" UI elements 315 this.updateSelectAllPagesUI(checkedCount); 316 317 // Update hidden input for form submission 318 this.updateAllPagesInput(); 319 320 this.updateCount(); 321 } 322 323 // Update the "select all pages" UI elements 324 updateSelectAllPagesUI(checkedCount) { 325 const showSelectAllPrompt = this.allOnPageSelected && this.hasMultiplePages && !this.allPagesSelected; 326 const showAllPagesSelected = this.allPagesSelected; 327 const showPageSelection = !showAllPagesSelected; 328 329 // "X of Y row(s) selected." - shown when NOT in all-pages mode 330 this.pageSelectionInfoTargets.forEach((el) => { 331 el.classList.toggle("hidden", !showPageSelection); 332 }); 333 334 // "Select all Y rows" prompt - shown when all on page selected but not all pages 335 this.selectAllPagesPromptTargets.forEach((el) => { 336 el.classList.toggle("hidden", !showSelectAllPrompt); 337 }); 338 339 // "All Y row(s) selected." - shown when all pages selected 340 this.allPagesSelectedInfoTargets.forEach((el) => { 341 el.classList.toggle("hidden", !showAllPagesSelected); 342 }); 343 } 344 345 // Update hidden input for form submission 346 // This allows the server to know if "all pages" is selected 347 updateAllPagesInput() {
348 this.allPagesInputTargets.forEach((input) => { 349 input.value = this.allPagesSelected ? "true" : "false"; 350 input.disabled = !this.allPagesSelected; 351 }); 352 } 353 354 // Update the count display 355 updateCount() { 356 if (!this.hasCountTarget) return; 357 358 // When all pages selected, show total count; otherwise show checked count 359 const displayCount = this.allPagesSelected 360 ? this.totalItemsValue 361 : this.checkboxTargets.filter((cb) => cb.checked && !cb.disabled).length; 362 363 this.countTargets.forEach((count) => { 364 count.textContent = `${displayCount}`; 365 }); 366 } 367 368 // Update the total by summing amounts from checked items 369 updateTotal() { 370 if (!this.hasTotalTarget) return; 371 372 // Start with the base amount (e.g., subscription fee) 373 let total = this.baseAmountValue; 374 375 // For each checked checkbox, find its associated amount element 376 this.checkboxTargets.forEach((checkbox) => { 377 if (checkbox.checked && !checkbox.disabled) { 378 // Find the amount target in the same container as the checkbox 379 const container = checkbox.closest('[data-controller*="checkbox-select-all"]') || this.element; 380 const allAmounts = Array.from(container.querySelectorAll('[data-checkbox-select-all-target*="amount"]')); 381 382 // Find the amount that's in the same parent as this checkbox 383 const checkboxParent = checkbox.closest("label, div, tr, li"); 384 if (checkboxParent) { 385 const amount = allAmounts.find((amt) => checkboxParent.contains(amt)); 386 if (amount) { 387 // Parse the number from the amount's text content 388 // Remove currency symbols, commas, /mo, /year, etc. 389 const text = amount.textContent.trim(); 390 const numberMatch = text.match(/[\d,]+\.?\d*/); 391 if (numberMatch) { 392 const value = parseFloat(numberMatch[0].replace(/,/g, "")); 393 if (!isNaN(value)) { 394 total += value; 395 } 396 } 397 } 398 } 399 } 400 }); 401 402 // Update all total targets with the calculated sum 403 this.totalTargets.forEach((totalElement) => { 404 // Get the original format from the element (to preserve currency symbols, etc.) 405 const originalText = totalElement.textContent; 406 const currencyMatch = originalText.match(/^[^\d]*/); 407 const suffixMatch = originalText.match(/[^\d,.]+$/); 408 409 const prefix = currencyMatch ? currencyMatch[0] : "$"; 410 const suffix = suffixMatch ? suffixMatch[0] : ""; 411 412 // Format with 2 decimal places 413 const formattedTotal = total.toFixed(2); 414 415 totalElement.textContent = `${prefix}${formattedTotal}${suffix}`; 416 }); 417 } 418 419 // Update state of all parent groups 420 updateAllParentStates() { 421 this.element.querySelectorAll('[data-checkbox-select-all-target="parent"]').forEach((container) => { 422 this.updateChildrenState(container); 423 }); 424 } 425 426 // Update children checkboxes when parent is toggled 427 toggleChildren(event) { 428 const parentCheckbox = event.target; 429 const isChecked = parentCheckbox.checked; 430 431 const container = parentCheckbox.closest('[data-checkbox-select-all-target="parent"]'); 432 if (!container) return; 433 434 // Select ALL child checkboxes at ALL nesting levels within this parent 435 const allChildCheckboxes = container.querySelectorAll('input[type="checkbox"]'); 436 allChildCheckboxes.forEach((checkbox) => { 437 // Skip the parent checkbox itself 438 if (checkbox !== parentCheckbox && !checkbox.disabled) { 439 checkbox.checked = isChecked; 440 checkbox.indeterminate = false; 441 } 442 }); 443 444 // Ensure parent stays in the correct state (checked or unchecked, not indeterminate) 445 parentCheckbox.indeterminate = false; 446 447 this.updateAllParentStates(); 448 this.updateSelectAllState(); 449 this.updateActionBarVisibility(); 450 this.updateTotal(); 451 } 452 453 // Update parent state based on children 454 updateChildrenState(container) { 455 const parentCheckboxInput = container.querySelector('input[type="checkbox"]'); 456 if (!parentCheckboxInput) return; 457 458 // Get all checkboxes in the container 459 const allCheckboxes = Array.from(container.querySelectorAll('input[type="checkbox"]')); 460 461 // Filter to get only children (exclude the parent itself) 462 const childCheckboxes = allCheckboxes.filter((cb) => cb !== parentCheckboxInput && !cb.disabled); 463 464 const checkedChildren = childCheckboxes.filter((cb) => cb.checked).length; 465 466 if (checkedChildren === 0) { 467 parentCheckboxInput.checked = false;
468 parentCheckboxInput.indeterminate = false; 469 } else if (checkedChildren === childCheckboxes.length) { 470 parentCheckboxInput.checked = true; 471 parentCheckboxInput.indeterminate = false; 472 } else { 473 parentCheckboxInput.checked = false; 474 parentCheckboxInput.indeterminate = true; 475 } 476 } 477 478 // Keyboard navigation handler 479 handleKeydown(event) { 480 const activeElement = document.activeElement; 481 482 // Check if the active element is the select-all checkbox 483 const isSelectAll = this.hasSelectAllTarget && activeElement === this.selectAllTarget; 484 485 // Check if the active element is one of our checkboxes 486 const currentIndex = this.checkboxTargets.indexOf(activeElement); 487 488 // If it's neither, ignore 489 if (!isSelectAll && currentIndex === -1) return; 490 491 // Handle arrow key navigation (ArrowDown and ArrowRight move forward, ArrowUp and ArrowLeft move backward) 492 if (event.key === "ArrowDown" || event.key === "ArrowRight") { 493 event.preventDefault(); 494 if (isSelectAll) { 495 // From select-all, go to first visible checkbox 496 this.focusFirstVisibleCheckbox(); 497 } else { 498 // Try to go to next checkbox, or loop back if at end 499 const moved = this.focusNextCheckbox(currentIndex); 500 if (!moved) { 501 // At the last visible checkbox, loop back to select-all or first checkbox 502 if (this.hasSelectAllTarget) { 503 this.selectAllTarget.focus(); 504 } else { 505 this.focusFirstVisibleCheckbox(); 506 } 507 } 508 } 509 } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") { 510 event.preventDefault(); 511 if (isSelectAll) { 512 // From select-all, loop to last visible checkbox 513 this.focusLastVisibleCheckbox(); 514 } else { 515 // Try to go to previous checkbox, or loop back if at start 516 const moved = this.focusPreviousCheckbox(currentIndex); 517 if (!moved) { 518 // At the first visible checkbox, go to select-all or loop to last 519 if (this.hasSelectAllTarget) { 520 this.selectAllTarget.focus(); 521 } else { 522 this.focusLastVisibleCheckbox(); 523 } 524 } 525 } 526 } 527 // Handle toggle key (if configured) - case insensitive 528 else if (this.hasToggleKeyValue && event.key.toLowerCase() === this.toggleKeyValue.toLowerCase()) { 529 event.preventDefault(); 530 531 // Only handle toggle for regular checkboxes (not select-all) 532 if (!isSelectAll) { 533 // Shift + toggle key: batch toggle like shift-click 534 if (event.shiftKey && this.lastCheckedIndex !== null) { 535 const anchor = this.lastCheckedIndex; 536 const targetState = this.lastCheckedState; 537 538 const newStart = Math.min(anchor, currentIndex); 539 const newEnd = Math.max(anchor, currentIndex); 540 541 // If we had a previous shift selection, handle items outside the new range 542 if (this.lastShiftEnd !== null) { 543 const prevStart = Math.min(anchor, this.lastShiftEnd); 544 const prevEnd = Math.max(anchor, this.lastShiftEnd); 545 546 // Deselect items that were in the previous range but not in the new range 547 this.checkboxTargets.forEach((cb, index) => { 548 if (!cb.disabled) { 549 const wasInPrevRange = index >= prevStart && index <= prevEnd;
550 const isInNewRange = index >= newStart && index <= newEnd; 551 552 if (wasInPrevRange && !isInNewRange) { 553 cb.checked = !targetState; 554 cb.indeterminate = false; 555 } 556 } 557 }); 558 } 559 560 // Apply the target state to all items in the new range 561 this.checkboxTargets.forEach((cb, index) => { 562 if (!cb.disabled && index >= newStart && index <= newEnd) { 563 cb.checked = targetState; 564 cb.indeterminate = false; 565 } 566 }); 567 568 // Remember this shift end 569 this.lastShiftEnd = currentIndex; 570 571 this.updateAllParentStates(); 572 this.updateSelectAllState(); 573 this.updateActionBarVisibility(); 574 this.updateTotal(); 575 } else { 576 // Normal toggle: just click the focused checkbox 577 activeElement.click(); 578 } 579 } else { 580 // For select-all, just click it 581 activeElement.click(); 582 } 583 } 584 } 585 586 // Check if a checkbox is hidden (e.g., inside a collapsed folder) 587 isCheckboxHidden(checkbox) {
588 let current = checkbox; 589 while (current && current !== this.element) { 590 if (current.hasAttribute("hidden") || current.classList.contains("hidden")) { 591 return true; 592 } 593 // Check for collapsed state via data-state attribute 594 if (current.getAttribute("data-state") === "closed") { 595 return true; 596 } 597 current = current.parentElement; 598 } 599 return false; 600 } 601 602 // Check if a checkbox can be focused (not disabled and not hidden) 603 isCheckboxFocusable(checkbox) { 604 return !checkbox.disabled && !this.isCheckboxHidden(checkbox); 605 } 606 607 // Focus a checkbox at a specific index (returns true if successful) 608 focusCheckboxAtIndex(index) { 609 if (index >= 0 && index < this.checkboxTargets.length) { 610 const checkbox = this.checkboxTargets[index]; 611 if (this.isCheckboxFocusable(checkbox)) { 612 checkbox.focus(); 613 this.scrollToCheckbox(checkbox); 614 return true; 615 } else { 616 // If disabled or hidden, try the next one 617 if (index < this.checkboxTargets.length - 1) { 618 return this.focusCheckboxAtIndex(index + 1); 619 } 620 } 621 } 622 return false; 623 } 624 625 // Focus the first visible and enabled checkbox 626 focusFirstVisibleCheckbox() { 627 for (let i = 0; i < this.checkboxTargets.length; i++) { 628 if (this.isCheckboxFocusable(this.checkboxTargets[i])) { 629 this.checkboxTargets[i].focus(); 630 this.scrollToCheckbox(this.checkboxTargets[i]); 631 return true; 632 } 633 } 634 return false; 635 } 636 637 // Focus the last visible and enabled checkbox 638 focusLastVisibleCheckbox() { 639 for (let i = this.checkboxTargets.length - 1; i >= 0; i--) { 640 if (this.isCheckboxFocusable(this.checkboxTargets[i])) { 641 this.checkboxTargets[i].focus(); 642 this.scrollToCheckbox(this.checkboxTargets[i]); 643 return true; 644 } 645 } 646 return false; 647 } 648 649 // Focus the next enabled and visible checkbox (returns true if moved) 650 focusNextCheckbox(currentIndex) { 651 for (let i = currentIndex + 1; i < this.checkboxTargets.length; i++) { 652 const checkbox = this.checkboxTargets[i]; 653 if (this.isCheckboxFocusable(checkbox)) { 654 checkbox.focus(); 655 this.scrollToCheckbox(checkbox); 656 return true; 657 } 658 } 659 return false; 660 } 661 662 // Focus the previous enabled and visible checkbox (returns true if moved) 663 focusPreviousCheckbox(currentIndex) { 664 for (let i = currentIndex - 1; i >= 0; i--) { 665 const checkbox = this.checkboxTargets[i]; 666 if (this.isCheckboxFocusable(checkbox)) { 667 checkbox.focus(); 668 this.scrollToCheckbox(checkbox); 669 return true; 670 } 671 } 672 return false; 673 } 674 675 // Scroll checkbox into view with padding 676 scrollToCheckbox(checkbox) { 677 // Find the row element (for table layouts) 678 const row = checkbox.closest("tr"); 679 const element = row || checkbox; 680 681 // Find the scrollable container 682 const content = element.closest(".overflow-y-auto, .overflow-auto"); 683 if (!content) return; 684 685 const contentRect = content.getBoundingClientRect(); 686 const elementRect = element.getBoundingClientRect(); 687 688 // Find sticky elements that reduce visible scroll area 689 const stickyHeader = content.querySelector(".sticky.top-0, thead .sticky"); 690 const stickyFooter = content.querySelector(".sticky.bottom-0"); 691 692 const stickyHeaderHeight = stickyHeader ? stickyHeader.offsetHeight : 0; 693 const stickyFooterHeight = stickyFooter && !stickyFooter.hidden ? stickyFooter.offsetHeight : 0; 694 695 // Calculate the element's position relative to the scrollable content 696 const elementRelativeTop = elementRect.top - contentRect.top; 697 const elementRelativeBottom = elementRelativeTop + elementRect.height; 698 const contentScrollTop = content.scrollTop; 699 const contentHeight = contentRect.height; 700 701 // Define padding: scroll when the element is within this distance from the edge 702 const scrollPadding = elementRect.height * 1; 703 704 // Adjust visible area by excluding sticky elements 705 const visibleTop = stickyHeaderHeight + scrollPadding; 706 const visibleBottom = contentHeight - stickyFooterHeight - scrollPadding; 707 708 // Check if we need to scroll down (element is too far down) 709 if (elementRelativeBottom > visibleBottom) { 710 content.scrollTop = contentScrollTop + (elementRelativeBottom - visibleBottom); 711 } 712 // Check if we need to scroll up (element is too far up) 713 else if (elementRelativeTop < visibleTop) { 714 content.scrollTop = contentScrollTop + (elementRelativeTop - visibleTop); 715 } 716 } 717};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.