From 69e2dd767bba5e3a187b844c4d9918d3f5d91615 Mon Sep 17 00:00:00 2001 From: Mats Johansen Date: Tue, 23 Sep 2025 10:58:43 +0200 Subject: [PATCH 01/16] feat(searchbar): add number inputs --- .../buttons/InfoButtonComponent.wc.svelte | 3 + .../QueryExplainButtonComponent.wc.svelte | 6 +- .../buttons/StoreDeleteButtonComponent.svelte | 4 +- src/components/catalogue/AddButton.svelte | 21 +- .../catalogue/NumberInputComponent.svelte | 75 +++- .../search-bar/SearchBarComponent.wc.svelte | 336 ++++++++++++------ src/types/queryData.ts | 41 ++- 7 files changed, 362 insertions(+), 124 deletions(-) diff --git a/src/components/buttons/InfoButtonComponent.wc.svelte b/src/components/buttons/InfoButtonComponent.wc.svelte index c1c05540..27c50c13 100644 --- a/src/components/buttons/InfoButtonComponent.wc.svelte +++ b/src/components/buttons/InfoButtonComponent.wc.svelte @@ -12,6 +12,7 @@ dialogueMaxWidth?: string; /** Info button in search bar is white and orange on hover */ inSearchBar?: boolean; + [key: string]: unknown; } let { @@ -20,6 +21,7 @@ alignDialogue = "center", dialogueMaxWidth = "300px", inSearchBar = false, + ...props }: Props = $props(); /** @@ -51,6 +53,7 @@ onclick={(e) => displayQueryInfo(e)} onfocusout={onFocusOut} style="width: {buttonSize}; height: {buttonSize};" + {...props} >
{#if queryItem} - + {#if typeof queryItem?.values[0].value === "string"}
{queryItem.name}: {queryItem.values[0].value} @@ -63,7 +65,7 @@ {:else}
- + {#if $queryStore.flat().length > 0}

{translate("query_info_header")} diff --git a/src/components/buttons/StoreDeleteButtonComponent.svelte b/src/components/buttons/StoreDeleteButtonComponent.svelte index f988eae3..7691c76b 100644 --- a/src/components/buttons/StoreDeleteButtonComponent.svelte +++ b/src/components/buttons/StoreDeleteButtonComponent.svelte @@ -14,9 +14,10 @@ index: number; item?: QueryItem; }; + onFocusOutOfSearchBar?: (event: FocusEvent) => void; } - let { itemToDelete }: Props = $props(); + let { itemToDelete, onFocusOutOfSearchBar = () => {} }: Props = $props(); const { type, index, item } = itemToDelete; @@ -69,6 +70,7 @@ lens-query-delete-button lens-query-delete-button-{type}" onclick={deleteItem} aria-label="Delete" + onfocusout={(event) => onFocusOutOfSearchBar(event)} > - let { ...props } = $props(); + interface Props { + inSearchBar: boolean; + [key: string]: unknown; + } + let { inSearchBar, ...props }: Props = $props(); -
-
{/if}
- + diff --git a/src/components/search-bar/SearchBarComponent.wc.svelte b/src/components/search-bar/SearchBarComponent.wc.svelte index af459528..afe67300 100644 --- a/src/components/search-bar/SearchBarComponent.wc.svelte +++ b/src/components/search-bar/SearchBarComponent.wc.svelte @@ -9,24 +9,30 @@ AggregatedValue, Category, Criteria, + NumericRangeCategory, } from "../../types/catalogue"; import { addItemToQuery, queryStore, activeQueryGroupIndex, } from "../../stores/query"; - import type { AutoCompleteItem, QueryItem } from "../../types/queryData"; + import { + type AutoCompleteCriterionItem, + type AutoCompleteItem, + type QueryItem, + } from "../../types/queryData"; import { v4 as uuidv4 } from "uuid"; import StoreDeleteButtonComponent from "../buttons/StoreDeleteButtonComponent.svelte"; import { catalogue } from "../../stores/catalogue"; import { facetCounts } from "../../stores/facetCounts"; import { lensOptions } from "../../stores/options"; import QueryExplainButtonComponent from "../buttons/QueryExplainButtonComponent.wc.svelte"; - import { onMount } from "svelte"; + import { onMount, tick } from "svelte"; import { showToast } from "../../stores/toasts"; import { translate } from "../../helpers/translations"; import { get } from "svelte/store"; import { SvelteURL } from "svelte/reactivity"; + import NumberInputComponent from "../catalogue/NumberInputComponent.svelte"; interface Props { /** The string to display when no matches are found */ @@ -60,11 +66,12 @@ const buildDatalistItemFromBottomCategoryRec = ( category: Category, criterion: Criteria, - ): AutoCompleteItem[] => { - let autoCompleteItems: AutoCompleteItem[] = []; + ): AutoCompleteCriterionItem[] => { + let autoCompleteItems: AutoCompleteCriterionItem[] = []; if ("criteria" in category) { if (criterion.visible == undefined && !criterion.visible) { autoCompleteItems.push({ + fieldType: "criterion", name: category.name, key: category.key, type: category.type, @@ -72,7 +79,7 @@ criterion: criterion, }); } - if (criterion.subgroup != undefined) { + if (criterion.subgroup !== undefined) { criterion.subgroup.forEach((criterion: Criteria) => { autoCompleteItems = autoCompleteItems.concat( buildDatalistItemFromBottomCategoryRec( @@ -90,10 +97,17 @@ category: Category, ): AutoCompleteItem[] => { let autoCompleteItems: AutoCompleteItem[] = []; - if ("criteria" in category) + if ( + category.fieldType === "autocomplete" || + category.fieldType === "single-select" + ) { category.criteria.forEach((criterion: Criteria) => { - if (criterion.visible == undefined && !criterion.visible) { + if ( + criterion.visible === true || + criterion.visible === undefined + ) { autoCompleteItems.push({ + fieldType: "criterion", name: category.name, key: category.key, type: category.type, @@ -112,6 +126,10 @@ }); } }); + } else if (category.fieldType !== "group") { + autoCompleteItems.push(category); + } + return autoCompleteItems; }; @@ -180,20 +198,27 @@ .replace(/^[0-9]*:/g, "") .toLocaleLowerCase(); - return ( - item.name.toLowerCase().includes(clearedInputValue) || - item.criterion.name.toLowerCase().includes(clearedInputValue) || - item.criterion.description - ?.toLowerCase() - .includes(clearedInputValue) - - /** - * Discussion: - * should it also be possible to search for the key? - */ - // item.key.toLocaleLowerCase().includes(clearedInputValue) || - // item.criterion.key.toLowerCase().includes(clearedInputValue) || - ); + switch (item.fieldType) { + case "criterion": { + return ( + item.name.toLowerCase().includes(clearedInputValue) || + item.criterion.name + .toLowerCase() + .includes(clearedInputValue) || + item.criterion.description + ?.toLowerCase() + .includes(clearedInputValue) + ); + } + case "number": + case "date": + case "string": + return item.name + .toLocaleLowerCase() + .includes(clearedInputValue); + default: + return false; + } }); }); @@ -214,6 +239,14 @@ inputItem: AutoCompleteItem, indexOfChosenStore: number = $queryStore.length, ): void => { + if ( + !( + inputItem.fieldType === "autocomplete" || + inputItem.fieldType === "single-select" + ) + ) + return; + /** * transform inputItem to QueryItem */ @@ -258,7 +291,7 @@ }; /** - * handles keyboard events to make input options selectable + * handles keyboard events to make input options selectable and form elements tabable * @param event - the keyboard event */ const handleKeyDown = (event: KeyboardEvent): void => { @@ -286,8 +319,63 @@ extractTargetGroupFromInputValue(), ); } + if (event.key === "Tab" && !event.shiftKey) { + if (activeDomElement) { + event.preventDefault(); + focusInSearchBarOption(0, focusedItemIndex, activeDomElement); + } + } }; + /** + * focuses an input element inside the search bar option + * @param inputIndex - the index of the form input element to focus + * @param newOptionIndex - the index of the option to focus + * @param newDomElement - the dom element of the option to focus + */ + async function focusInSearchBarOption( + inputIndex: number, + newOptionIndex: number, + newDomElement: HTMLElement, + ): Promise { + focusedItemIndex = newOptionIndex; + activeDomElement = newDomElement; + + // needs to rerender first, otherwise element will be lost + await tick(); + const inputs = Array.from(activeDomElement.querySelectorAll("input")); + inputs[inputIndex]?.focus(); + } + + function resetToEmptySearchBar(focus: boolean = true): void { + inputValue = ""; + focusedItemIndex = -1; + activeDomElement = undefined; + if (focus) { + focusSearchbar(); + } + } + + // needed as function to be passed to children + function focusSearchbar(): void { + searchBarInput.focus(); + } + + const optionElements: HTMLElement[] = $state([]); + + let searchBarContainer: HTMLElement; + let optionsList: HTMLUListElement; + + function onFocusOutOfSearchBar(event: FocusEvent): void { + if ( + event.relatedTarget instanceof HTMLElement && + searchBarContainer.contains(event.relatedTarget) + ) { + return; + } + resetToEmptySearchBar(false); + } + /** * scrolls the active dom element into view when it is out of view * @param activeDomElement - the active dom element @@ -319,14 +407,6 @@ } }); - /** - * handles click events to make input options selectable - * @param inputOption - the input option to add to the query store - */ - const selectItemByClick = (inputOption: AutoCompleteItem): void => { - addInputValueToStore(inputOption, extractTargetGroupFromInputValue()); - }; - /** * returns the input option with the matched substring wrapped in tags * @param inputOption - the input option to bold @@ -407,6 +487,7 @@ part="lens-searchbar {index === $activeQueryGroupIndex ? 'lens-searchbar-active' : ''}" + bind:this={searchBarContainer} > {#if queryGroup !== undefined && queryGroup.length > 0}
@@ -425,6 +506,7 @@ ...queryItem, values: [value], }} + onfocusout={onFocusOutOfSearchBar} /> {#if queryItem.values.length > 1} { - autoCompleteOpen = false; - inputValue = ""; - }} + onfocusout={onFocusOutOfSearchBar} /> - {#if autoCompleteOpen && inputValue.length > 2} -
    + {#if autoCompleteOpen && inputValue.length} +
      {#if inputOptions?.length > 0} - {#each inputOptions as inputOption, i} + {#each inputOptions as inputOption, i (inputOption.key + i)} + {#if inputOptions .map((option) => option.name) .indexOf(inputOption.name) === i} @@ -476,83 +556,136 @@ {@html getBoldedText(inputOption.name)}
{/if} - {#if i === focusedItemIndex} - - + -
  • selectItemByClick(inputOption)} - > -
    - - {@html getBoldedText( - inputOption.criterion.name, - )} -
    -
    + addInputValueToStore( + inputOption, + extractTargetGroupFromInputValue(), + )} > - {#if inputOption.criterion.description} +
    {@html getBoldedText( - inputOption.criterion.description, + inputOption.criterion.name, )} - {/if} -
    - {#if $facetCounts[inputOption.key] !== undefined} +
    - {$facetCounts[inputOption.key][ - inputOption.criterion.key - ] ?? 0} + {#if inputOption.criterion.description} + + {@html getBoldedText( + inputOption.criterion.description, + )} + {/if}
    - {/if} -
  • - {:else} - - + -
  • selectItemByClick(inputOption)} - > -
    - - {@html getBoldedText( - inputOption.criterion.name, - )} -
    -
    + addInputValueToStore( + inputOption, + extractTargetGroupFromInputValue(), + )} > - {#if inputOption.criterion.description} +
    {@html getBoldedText( - inputOption.criterion.description, + inputOption.criterion.name, )} - {/if} -
    - {#if $facetCounts[inputOption.key] !== undefined} +
    - {$facetCounts[inputOption.key][ - inputOption.criterion.key - ] ?? 0} + {#if inputOption.criterion.description} + + {@html getBoldedText( + inputOption.criterion.description, + )} + {/if}
    - {/if} -
  • + {#if $facetCounts[inputOption.key] !== undefined} +
    + {$facetCounts[inputOption.key][ + inputOption.criterion.key + ] ?? 0} +
    + {/if} + + {/if} + {/if} + {#if inputOption.fieldType === "number"} + {#if i === focusedItemIndex} +
  • + + focusInSearchBarOption( + elementIndex, + i, + optionElements[i], + )} + {resetToEmptySearchBar} + {focusSearchbar} + {onFocusOutOfSearchBar} + /> +
  • + {:else} +
  • + + focusInSearchBarOption( + elementIndex, + i, + optionElements[i], + )} + {resetToEmptySearchBar} + {focusSearchbar} + /> +
  • + {/if} {/if} {/each} {:else} @@ -564,7 +697,10 @@
  • {typeMoreMessage}
  • {/if} - +