improve tabbing

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-11 19:20:05 +02:00
parent c64f5a7dec
commit a450185949
4 changed files with 147 additions and 50 deletions
+71 -16
View File
@@ -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
@@ -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
@@ -259,6 +259,33 @@
</div>
</label>
<div class="relative">
{#if !isFocused}
<div class="absolute left-1 top-1/2 transform -translate-y-1/2 flex bg-transparent z-10">
<button
type="button"
class="w-8 h-8 text-base transition-all duration-200 rounded-lg border flex items-center justify-center {type ===
'page'
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'border-gray-200 dark:border-gray-600 bg-grayblue-light dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30'}"
on:click={() => handleTypeChange('page')}
title="Page"
>
📄
</button>
<button
type="button"
class="w-8 h-8 text-base transition-all duration-200 rounded-lg border flex items-center justify-center ml-0.5 {type ===
'proxy'
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'border-gray-200 dark:border-gray-600 bg-grayblue-light dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30'}"
on:click={() => handleTypeChange('proxy')}
title="Proxy"
>
<ProxySvgIcon size="w-5 h-5" />
</button>
</div>
{/if}
<div
class="flex items-center relative"
class:w-28={size == 'small'}
@@ -290,34 +317,6 @@
{required}
/>
<!-- Type selector buttons inside the input on the left - hidden when focused -->
{#if !isFocused}
<div class="absolute left-1 top-1/2 transform -translate-y-1/2 flex bg-transparent">
<button
type="button"
class="w-8 h-8 text-base transition-all duration-200 rounded-lg border flex items-center justify-center {type ===
'page'
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'border-gray-200 dark:border-gray-600 bg-grayblue-light dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30'}"
on:click={() => handleTypeChange('page')}
title="Page"
>
📄
</button>
<button
type="button"
class="w-8 h-8 text-base transition-all duration-200 rounded-lg border flex items-center justify-center ml-0.5 {type ===
'proxy'
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'border-gray-200 dark:border-gray-600 bg-grayblue-light dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30'}"
on:click={() => handleTypeChange('proxy')}
title="Proxy"
>
<ProxySvgIcon size="w-5 h-5" />
</button>
</div>
{/if}
<!-- Search icon - visible when dropdown is open -->
{#if showDropdown}
<img
+34 -3
View File
@@ -1,6 +1,6 @@
<script>
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}
<svg