diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte index 705198c..f76f4c5 100644 --- a/frontend/src/lib/components/Modal.svelte +++ b/frontend/src/lib/components/Modal.svelte @@ -70,7 +70,24 @@ // If current element is not found (-1), try to find a related element if (currentIndex === -1) { // Check if the current element is inside a TextFieldSelect or similar component - const parentComponent = currentlyFocused?.closest('.relative'); + const parentComponent = currentlyFocused?.closest('.textfield-select-container'); + if (parentComponent) { + // Look for the input element within the same component + const inputInComponent = parentComponent.querySelector('input'); + if (inputInComponent) { + const inputIndex = focusableElements.indexOf(inputInComponent); + if (inputIndex !== -1) { + // Use the input's position for navigation and continue tab flow + currentIndex = inputIndex; + } + } + } + } + + // If we still can't find the element, handle it gracefully + if (currentIndex === -1) { + // Check if the current element is inside a TextFieldSelect or similar component + const parentComponent = currentlyFocused?.closest('.textfield-select-container'); if (parentComponent) { // Look for the input element within the same component const inputInComponent = parentComponent.querySelector('input'); @@ -141,23 +158,70 @@ const elements = modalElement.querySelectorAll(focusableSelectors.join(', ')); return Array.from(elements).filter((el) => { - // Exclude dropdown option buttons from TextFieldSelect components + // exclude dropdown option buttons from TextFieldSelect components if (el.role === 'option' && el.closest('[role="listbox"]')) { return false; } - return el.offsetWidth > 0 && el.offsetHeight > 0 && !el.hasAttribute('hidden'); + + // check if element is truly visible (has dimensions and not hidden) + if (el.offsetWidth === 0 && el.offsetHeight === 0) { + return false; + } + + // check for hidden attribute + if (el.hasAttribute('hidden')) { + return false; + } + + // check computed styles for visibility + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + + // check if any parent container is hidden (for multi-step forms) + let parent = el.parentElement; + while (parent && parent !== modalElement) { + const parentStyle = window.getComputedStyle(parent); + if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden') { + return false; + } + parent = parent.parentElement; + } + + return true; }); }; const updateFocusableElements = () => { focusableElements = getFocusableElements(); - // Exclude the close button from being the first focusable element + // Reorder elements: form controls first, then navigation buttons, then close button + const formElements = []; + const navigationButtons = []; const closeButton = modalElement?.querySelector('[data-close-button]'); - if (closeButton && focusableElements.length > 1) { - focusableElements = focusableElements.filter((el) => el !== closeButton); - focusableElements.push(closeButton); // Add close button to the end + + focusableElements.forEach((el) => { + if (el === closeButton) { + // close button goes last + return; + } else if ( + el instanceof HTMLButtonElement && + (el.textContent?.trim() === 'Next' || el.textContent?.trim() === 'Previous') + ) { + navigationButtons.push(el); + } else { + // all other elements (form fields, form control buttons, etc.) go first + formElements.push(el); + } + }); + + // Rebuild focusable elements in desired order: form controls, then navigation, then close + focusableElements = [...formElements, ...navigationButtons]; + if (closeButton) { + focusableElements.push(closeButton); } + firstFocusableElement = focusableElements[0] || null; lastFocusableElement = focusableElements[focusableElements.length - 1] || null; }; @@ -186,20 +250,11 @@ // Exposed function to reset tab focus when modal content changes const handleResetTabFocus = async () => { - // Only reset focus if no element is currently focused or if focus is outside modal - const currentFocused = document.activeElement; - const isInModal = modalElement?.contains(currentFocused); - // Check if a TextFieldSelect is currently selecting an option const isTextFieldSelectActive = modalElement?.querySelector('[data-selecting="true"]'); await tick(); updateFocusableElements(); - - // Only focus first element if focus is not properly within the modal and no selection is happening - if (!isInModal && !isTextFieldSelectActive && firstFocusableElement) { - firstFocusableElement.focus(); - } }; // Call resetTabFocus when it changes diff --git a/frontend/src/lib/components/TextFieldSelect.svelte b/frontend/src/lib/components/TextFieldSelect.svelte index 07c7a95..bbb60cf 100644 --- a/frontend/src/lib/components/TextFieldSelect.svelte +++ b/frontend/src/lib/components/TextFieldSelect.svelte @@ -25,6 +25,7 @@ let showDropdown = false; let inputElement; let dropdownElement; + let justSelected = false; // Simple function to filter options based on input const filterOptions = (searchValue) => { @@ -45,9 +46,13 @@ // Show dropdown when focused and there are options const handleFocus = () => { activeFormElement.set(id); - showDropdown = true; - hasTyped = false; // Reset typing flag to show all options - allOptions = [...optionsArray]; // Show all options on focus + // Don't open dropdown if we just selected something + if (!justSelected) { + showDropdown = true; + hasTyped = false; // Reset typing flag to show all options + allOptions = [...optionsArray]; // Show all options on focus + } + justSelected = false; // Reset the flag }; // Handle input changes for filtering @@ -63,7 +68,14 @@ value = option; showDropdown = false; hasTyped = false; // Reset typing flag after selection + justSelected = true; // Set flag to prevent dropdown reopening onSelect(option); + // Focus the input field after selection without reopening dropdown + setTimeout(() => { + if (inputElement) { + inputElement.focus(); + } + }, 0); }; // Close dropdown diff --git a/frontend/src/lib/components/form/TextFieldSelectWithType.svelte b/frontend/src/lib/components/form/TextFieldSelectWithType.svelte index 791618a..aff5899 100644 --- a/frontend/src/lib/components/form/TextFieldSelectWithType.svelte +++ b/frontend/src/lib/components/form/TextFieldSelectWithType.svelte @@ -259,6 +259,33 @@
+ {#if !isFocused} +
+ + +
+ {/if} +
- - {#if !isFocused} -
- - -
- {/if} - {#if showDropdown} import { api } from '$lib/api/apiProxy.js'; - import { onMount } from 'svelte'; + import { onMount, tick } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { newTableURLParams } from '$lib/service/tableURLParams.js'; @@ -315,15 +315,43 @@ }; }); - const nextStep = () => { + const nextStep = async () => { if (validateCurrentStep()) { currentStep = Math.min(currentStep + 1, campaignSteps.length); modalError = ''; + // reset tab focus after dom update - only for explicit step navigation + await tick(); + // focus first element in current step + setTimeout(() => { + const currentStepContainer = document.querySelector(`#step-${currentStep}`); + if (currentStepContainer) { + const firstFocusable = currentStepContainer.querySelector( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + if (firstFocusable && firstFocusable instanceof HTMLElement) { + firstFocusable.focus(); + } + } + }, 0); } }; - const previousStep = () => { + const previousStep = async () => { currentStep = Math.max(currentStep - 1, 1); + // reset tab focus after dom update - only for explicit step navigation + await tick(); + // focus first element in current step + setTimeout(() => { + const currentStepContainer = document.querySelector(`#step-${currentStep}`); + if (currentStepContainer) { + const firstFocusable = currentStepContainer.querySelector( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + if (firstFocusable && firstFocusable instanceof HTMLElement) { + firstFocusable.focus(); + } + } + }, 0); }; const validateCurrentStep = () => { @@ -1061,6 +1089,9 @@ : 'bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-300 border-2 border-gray-300 dark:border-gray-600' } `} + role="tab" + aria-selected={currentStep === index + 1} + aria-label={`Step ${index + 1}: ${step.name}`} > {#if currentStep > index + 1}