mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-05-23 16:29:46 +02:00
improve tabbing and styling on TextFieldMultiSelect
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -1,124 +1,267 @@
|
||||
<script>
|
||||
import { afterUpdate, onDestroy, onMount } from 'svelte';
|
||||
import ToolTip from './ToolTip.svelte';
|
||||
import { activeFormElement } from '$lib/store/activeFormElement';
|
||||
import { activeFormElementSubscribe } from '$lib/store/activeFormElement';
|
||||
|
||||
const _id = Symbol();
|
||||
export let _id = Symbol();
|
||||
export let id;
|
||||
// bind a element to this component input field
|
||||
// use like <TextField bind:bindTo={varYouWantToBindTheInputFieldTo} />
|
||||
export let bindTo = null;
|
||||
export let defaultValue = []; // default selected value
|
||||
// for binding value, it is an array of the selected items
|
||||
export let defaultValue = [];
|
||||
export let value = defaultValue;
|
||||
export let placeholder = 'Select...';
|
||||
export let required = false;
|
||||
export let options = [];
|
||||
export let onSelect = (value) => {};
|
||||
export let onRemove = (value) => {};
|
||||
export let toolTipText = '';
|
||||
export let optional = false;
|
||||
export let hidden = false;
|
||||
export let size = 'normal';
|
||||
export let inline = false;
|
||||
|
||||
let filteredOptions = [...options];
|
||||
// the input value is only used for searching
|
||||
// Ensure options is always an array
|
||||
$: optionsArray = Array.isArray(options) ? options : Array.from(options);
|
||||
|
||||
let allOptions = [];
|
||||
let showDropdown = false;
|
||||
let inputElement;
|
||||
let dropdownElement;
|
||||
let justSelected = false;
|
||||
let inputValue = '';
|
||||
// bind to parent form element, if there is one
|
||||
|
||||
// Simple function to filter options based on input
|
||||
const filterOptions = (searchValue) => {
|
||||
if (!searchValue) {
|
||||
return [...optionsArray];
|
||||
}
|
||||
return optionsArray.filter(
|
||||
(opt) => opt && opt.toLowerCase && opt.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
// Track if user has typed (for filtering) vs just focused (show all)
|
||||
let hasTyped = false;
|
||||
|
||||
// Update filtered options - show all on focus, filter only when typed
|
||||
$: allOptions = hasTyped ? filterOptions(inputValue) : [...optionsArray];
|
||||
|
||||
// Handle focus - don't auto-open dropdown
|
||||
const handleFocus = () => {
|
||||
// Just set focus state, don't open dropdown
|
||||
justSelected = false; // Reset the flag
|
||||
};
|
||||
|
||||
// Handle click to open dropdown
|
||||
const handleClick = () => {
|
||||
if (!showDropdown) {
|
||||
showDropdown = true;
|
||||
hasTyped = false; // Reset typing flag to show all options
|
||||
allOptions = [...optionsArray]; // Show all options on click
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input changes for filtering
|
||||
const handleInput = (e) => {
|
||||
inputValue = e.target.value;
|
||||
showDropdown = true;
|
||||
hasTyped = true; // User has typed, enable filtering
|
||||
allOptions = filterOptions(inputValue);
|
||||
};
|
||||
|
||||
// Select an option
|
||||
const selectOption = (option) => {
|
||||
// Check if option is already selected
|
||||
if (!value.includes(option)) {
|
||||
value = [...value, option];
|
||||
onSelect(option);
|
||||
}
|
||||
inputValue = '';
|
||||
showDropdown = false;
|
||||
hasTyped = false; // Reset typing flag after selection
|
||||
justSelected = true; // Set flag to prevent dropdown reopening
|
||||
// Focus the input field after selection without reopening dropdown
|
||||
setTimeout(() => {
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Remove a selected option
|
||||
const removeSelectedOption = (event) => {
|
||||
const optionToRemove = event.target.dataset.value;
|
||||
value = value.filter((item) => item !== optionToRemove);
|
||||
onRemove(optionToRemove);
|
||||
};
|
||||
|
||||
// Close dropdown
|
||||
const closeDropdown = () => {
|
||||
showDropdown = false;
|
||||
hasTyped = false; // Reset typing flag when closing
|
||||
};
|
||||
|
||||
// Handle blur to close dropdown when tabbing out
|
||||
const handleBlur = (e) => {
|
||||
// Use setTimeout to allow click events on options to complete first
|
||||
setTimeout(() => {
|
||||
const focusedElement = document.activeElement;
|
||||
const container = inputElement?.closest('.textfield-select-container');
|
||||
|
||||
// If focus moved outside the component, close dropdown
|
||||
if (!container?.contains(focusedElement)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e) => {
|
||||
if (!showDropdown) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (allOptions.length === 1) {
|
||||
selectOption(allOptions[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const firstOption = dropdownElement?.querySelector('button');
|
||||
firstOption?.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle option keyboard navigation
|
||||
const handleOptionKeyDown = (e, option) => {
|
||||
if (e.key === 'Tab') {
|
||||
// Let tab work normally but close dropdown
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectOption(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeDropdown();
|
||||
inputElement?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const currentButton = e.target;
|
||||
const prevButton =
|
||||
currentButton.parentElement?.previousElementSibling?.querySelector('button');
|
||||
if (prevButton) {
|
||||
prevButton.focus();
|
||||
} else {
|
||||
inputElement?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const currentButton = e.target;
|
||||
const nextButton = currentButton.parentElement?.nextElementSibling?.querySelector('button');
|
||||
if (nextButton) {
|
||||
nextButton.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clicks outside to close dropdown
|
||||
const handleOutsideClick = (e) => {
|
||||
if (!showDropdown) return;
|
||||
|
||||
const container = inputElement?.closest('.textfield-select-container');
|
||||
if (container && !container.contains(e.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
// Bind to parent form element
|
||||
let parentForm = null;
|
||||
// listen to parent form reset event, if one exists
|
||||
let parentFormResetListener = null;
|
||||
let showSelection = false;
|
||||
|
||||
onMount(() => {
|
||||
value = value ?? defaultValue;
|
||||
const unsubscribe = activeFormElement.subscribe((activeId) => {
|
||||
showSelection = activeId === _id;
|
||||
});
|
||||
value = value || defaultValue;
|
||||
const unsubscribe = activeFormElementSubscribe(_id, closeDropdown);
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
};
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (options.length && inputValue === '') {
|
||||
filteredOptions = [...options];
|
||||
if (inputElement) {
|
||||
bindTo = inputElement;
|
||||
}
|
||||
if (!parentForm) {
|
||||
parentForm = bindTo.closest('form');
|
||||
if (!parentForm) {
|
||||
return;
|
||||
|
||||
if (!parentForm && inputElement) {
|
||||
parentForm = inputElement.closest('form');
|
||||
if (parentForm) {
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
|
||||
event.preventDefault();
|
||||
value = defaultValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (parentFormResetListener) {
|
||||
if (parentFormResetListener && parentForm) {
|
||||
parentForm.removeEventListener('reset', parentFormResetListener);
|
||||
}
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
});
|
||||
|
||||
const removeSelection = (event) => {
|
||||
const v = event.target.dataset.value;
|
||||
// svelte remove the item from the selected array if it exists
|
||||
value = value.filter((item) => item !== v);
|
||||
onRemove(v);
|
||||
};
|
||||
// Generate unique IDs for accessibility
|
||||
const comboboxId = id || `textfield-multiselect-${_id.toString()}`;
|
||||
const listboxId = `${comboboxId}-listbox`;
|
||||
const labelId = `${comboboxId}-label`;
|
||||
|
||||
const closeSelection = () => {
|
||||
showSelection = false;
|
||||
// stop listening for a click
|
||||
document.removeEventListener('click', closeSelection);
|
||||
};
|
||||
|
||||
const onFocus = (e) => {
|
||||
inputValue = '';
|
||||
showSelection = true;
|
||||
activeFormElement.set(_id);
|
||||
// when we focus in the input field, we add a listener for a click anywhere
|
||||
// we add a small timeout to ensure the closeSelection is not closed at once
|
||||
// when we have focus in the box
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeSelection);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const onKeyUp = () => {
|
||||
if (inputValue === '') {
|
||||
filteredOptions = [...options];
|
||||
return;
|
||||
}
|
||||
filteredOptions = filteredOptions.filter((opt) => opt.includes(inputValue));
|
||||
};
|
||||
|
||||
/** @type {(event: Event) => void} */
|
||||
const onChange = (event) => {
|
||||
// check if the value already exists in the selected array
|
||||
const target = /** @type {HTMLInputElement} */ (event.target);
|
||||
if (!value.includes(target.value) && options.includes(target.value)) {
|
||||
value = [target.value, ...value];
|
||||
// TODO remove it from available selections and ensure the list works when removing it agian
|
||||
}
|
||||
bindTo.blur();
|
||||
};
|
||||
|
||||
const onClickSelectedOption = (option) => {
|
||||
// if the option is not already selected, we add it
|
||||
if (!value.find((v) => v === option)) {
|
||||
value = [option, ...value];
|
||||
onSelect(option);
|
||||
}
|
||||
showSelection = false;
|
||||
};
|
||||
// Reactive statements for accessibility
|
||||
$: hasValue = inputValue && inputValue !== '';
|
||||
$: ariaExpanded = showDropdown;
|
||||
$: displayValue = value.length > 0 ? `${value.length} selected` : '';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-start">
|
||||
<label class="flex flex-col py-2 relative">
|
||||
<div
|
||||
class="flex justify-start textfield-select-container"
|
||||
class:hidden
|
||||
class:flex-col={!inline}
|
||||
class:flex-row={inline}
|
||||
>
|
||||
<label class="flex flex-col py-2 relative" class:py-2={!inline} class:pr-2={inline}>
|
||||
<div class="flex items-center">
|
||||
<p
|
||||
class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200"
|
||||
id={labelId}
|
||||
class="font-semibold text-slate-600 dark:text-gray-400 py-1 transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
@@ -136,76 +279,141 @@
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<div class="flex items-center relative w-60">
|
||||
<div class="relative mb-6">
|
||||
<div
|
||||
class="flex items-center relative"
|
||||
class:w-28={size == 'small'}
|
||||
class:w-60={size == 'normal'}
|
||||
>
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
bind:this={bindTo}
|
||||
role="combobox"
|
||||
id={comboboxId}
|
||||
aria-labelledby={labelId}
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
bind:value={inputValue}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
if (!options.includes(inputValue)) {
|
||||
inputValue = '';
|
||||
}
|
||||
}}
|
||||
on:change={onChange}
|
||||
on:keyup={onKeyUp}
|
||||
on:click|stopPropagation={() => {}}
|
||||
{id}
|
||||
required={required && !value.length}
|
||||
on:focus={handleFocus}
|
||||
on:blur={handleBlur}
|
||||
on:input={handleInput}
|
||||
on:keydown={handleKeyDown}
|
||||
on:click={handleClick}
|
||||
autocomplete="off"
|
||||
class="w-full relative rounded-md py-2 pl-4 focus:pl-10 text-gray-600 dark:text-gray-300 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 dark:focus:border-highlight-blue/80 focus:bg-gray-100 dark:focus:bg-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 font-normal cursor-pointer focus:cursor-text transition-colors duration-200"
|
||||
class="w-full relative rounded-md py-2 pr-10 text-gray-600 dark:text-gray-300 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 dark:focus:border-highlight-blue/80 focus:bg-gray-100 dark:focus:bg-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 font-normal cursor-pointer focus:cursor-text transition-colors duration-200"
|
||||
class:pl-10={showDropdown}
|
||||
class:pl-4={!showDropdown}
|
||||
class:text-gray-400={!hasValue && !showDropdown && value.length === 0}
|
||||
placeholder={!hasValue && !showDropdown && value.length === 0
|
||||
? placeholder
|
||||
: showDropdown
|
||||
? ''
|
||||
: displayValue}
|
||||
required={required && !value.length}
|
||||
/>
|
||||
{#if showSelection}
|
||||
<!-- Search icon - visible when dropdown is open -->
|
||||
{#if showDropdown}
|
||||
<img
|
||||
class="absolute w-4 left-4 pointer-events-none select-none"
|
||||
class="absolute w-4 left-3 select-none pointer-events-none z-10"
|
||||
src="/search-icon.svg"
|
||||
alt="search"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
<img class="absolute pointer-events-none w-4 right-4" src="/arrow.svg" alt="drop down" />
|
||||
</div>
|
||||
{#if showSelection}
|
||||
<div class="w-60 absolute top-10 z-50">
|
||||
<ul
|
||||
class="bg-gray-100 dark:bg-gray-900 list-none mt-4 rounded-md min-w-fit shadow-md border border-gray-200 dark:border-gray-700/60 max-h-40 overflow-y-scroll transition-colors duration-200"
|
||||
<!-- Clear button for optional fields -->
|
||||
{#if optional === true && value.length > 0}
|
||||
<button
|
||||
class="absolute right-10 z-10"
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
value = [];
|
||||
inputValue = '';
|
||||
inputElement?.focus();
|
||||
}}
|
||||
>
|
||||
{#if options.length}
|
||||
{#each filteredOptions as option}
|
||||
<li>
|
||||
<img class="w-4" src="/remove-value.svg" alt="" />
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Dropdown arrow -->
|
||||
<img
|
||||
class="absolute pointer-events-none w-4 right-3"
|
||||
class:right-12={optional === true && value.length > 0}
|
||||
src="/arrow.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<!-- Dropdown list -->
|
||||
{#if showDropdown}
|
||||
<div
|
||||
bind:this={dropdownElement}
|
||||
class="absolute top-10 z-50"
|
||||
class:w-28={size == 'small'}
|
||||
class:w-60={size == 'normal'}
|
||||
>
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-labelledby={labelId}
|
||||
class="bg-gray-100 dark:bg-gray-900 list-none mt-4 z-[999] rounded-md min-w-fit shadow-md border border-gray-200 dark:border-gray-700/60 max-h-40 overflow-y-scroll transition-colors duration-200"
|
||||
>
|
||||
{#if allOptions.length}
|
||||
{#each allOptions as option, index}
|
||||
<li role="none">
|
||||
<button
|
||||
class="w-full text-left bg-slate-100 dark:bg-gray-900 rounded-md text-gray-600 dark:text-gray-300 hover:bg-grayblue-dark dark:hover:bg-highlight-blue/40 hover:text-white py-2 px-2 cursor-pointer transition-colors duration-200"
|
||||
on:click={() => {
|
||||
onClickSelectedOption(option);
|
||||
id="{listboxId}-option-{index}"
|
||||
role="option"
|
||||
aria-selected={value.includes(option)}
|
||||
class="w-full text-left bg-slate-100 dark:bg-gray-900 rounded-md text-gray-600 dark:text-gray-300 hover:bg-grayblue-dark dark:hover:bg-highlight-blue/40 hover:text-white py-2 px-2 cursor-pointer focus:bg-grayblue-dark dark:focus:bg-highlight-blue/40 focus:text-white focus:outline-none transition-colors duration-200"
|
||||
class:bg-blue-600={value.includes(option)}
|
||||
class:text-white={value.includes(option)}
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectOption(option);
|
||||
}}
|
||||
on:keydown={(e) => handleOptionKeyDown(e, option)}
|
||||
on:blur={handleBlur}
|
||||
>
|
||||
{option}
|
||||
{#if value.includes(option)}
|
||||
<span class="float-right">✓</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
<li
|
||||
role="none"
|
||||
class="w-full bg-slate-100 dark:bg-gray-900 text-gray-600 dark:text-gray-400 py-2 px-2 transition-colors duration-200"
|
||||
>
|
||||
List is empty
|
||||
No options available
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row flex-wrap mb-4">
|
||||
{#each value as option}
|
||||
<button
|
||||
on:click|preventDefault={removeSelection}
|
||||
on:keypress|preventDefault={removeSelection}
|
||||
data-value={option}
|
||||
class="flex flex-row items-center bg-gray-100 dark:bg-gray-800/60 hover:bg-gray-200 dark:hover:bg-gray-700/80 px-2 py-2 mt-2 mr-2 rounded-md text-gray-900 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
{option}
|
||||
<img class="w-4 ml-2 pointer-events-none" src="/delete2.svg" alt="delete" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Selected items -->
|
||||
{#if value.length > 0}
|
||||
<div class="flex flex-row flex-wrap mt-4 gap-2">
|
||||
{#each value as option}
|
||||
<button
|
||||
type="button"
|
||||
on:click|preventDefault={removeSelectedOption}
|
||||
on:keypress|preventDefault={removeSelectedOption}
|
||||
data-value={option}
|
||||
class="flex flex-row items-center bg-gray-100 dark:bg-gray-800/60 hover:bg-gray-200 dark:hover:bg-gray-700/80 px-2 py-1 rounded-md text-gray-900 dark:text-gray-300 text-sm transition-colors duration-200"
|
||||
aria-label="Remove {option}"
|
||||
>
|
||||
{option}
|
||||
<img class="w-3 ml-2 pointer-events-none" src="/delete2.svg" alt="" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1201,14 +1201,16 @@
|
||||
<!-- Recipients Step -->
|
||||
<FormColumns id={'step-2'}>
|
||||
<FormColumn>
|
||||
<TextFieldMultiSelect
|
||||
id="recipientGroupIDs"
|
||||
bind:value={formValues.recipientGroups}
|
||||
required
|
||||
onSelect={onAddReceipientGroup}
|
||||
onRemove={onRemoveReceipientGroup}
|
||||
options={recipientGroupMap.values()}>Recipient Groups</TextFieldMultiSelect
|
||||
>
|
||||
<div class="pb-32">
|
||||
<TextFieldMultiSelect
|
||||
id="recipientGroupIDs"
|
||||
bind:value={formValues.recipientGroups}
|
||||
required
|
||||
onSelect={onAddReceipientGroup}
|
||||
onRemove={onRemoveReceipientGroup}
|
||||
options={recipientGroupMap.values()}>Recipient Groups</TextFieldMultiSelect
|
||||
>
|
||||
</div>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
{:else if currentStep === 3}
|
||||
|
||||
Reference in New Issue
Block a user