From 1ecaf0164ff476bcd4db54fdc9c2988f67274452 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 20 Aug 2026 09:27:38 +0200 Subject: [PATCH 1/8] Fix openMenuOnFocus always false This was already false per default, but was set to false explicitly to fix a tab navigation issue in the metadata tab of events/series dialog. So this sets openMenuOnFocus to false in RenderFields, so that it may be true in other components again --- src/components/shared/DropDown.tsx | 1 - src/components/shared/wizard/RenderField.tsx | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 03dd4a8078..372c7a8b75 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -229,7 +229,6 @@ const DropDown = ({ t("SELECT_NO_MATCHING_RESULTS")} /> ); diff --git a/src/components/shared/wizard/RenderField.tsx b/src/components/shared/wizard/RenderField.tsx index d2c72ea52f..eb6800cdae 100644 --- a/src/components/shared/wizard/RenderField.tsx +++ b/src/components/shared/wizard/RenderField.tsx @@ -422,7 +422,9 @@ const EditableSingleSelectDropDown = ({ } customCSS={{ isMetadataStyle: focused ? false : true, width: "100%" }} handleMenuIsOpen={(open: boolean) => setFocused(open)} - openMenuOnFocus + // Deliberately false: with the menu open, Tab breaks keyboard + // navigation through the metadata form fields (see f44c9b7). + openMenuOnFocus={false} autoFocus={isFirstField} skipTranslate={!metadataField.translatable} /> From efc8d55c37676dac6b65c870f224a0ad6b05ec1d Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 20 Aug 2026 09:44:38 +0200 Subject: [PATCH 2/8] Always copy options array Before we only made a copy if skipTranslate was true. This could have cause errors with immutable arrays, so it is probably better to copy everytime. --- src/components/shared/DropDown.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 372c7a8b75..2c7d1c0481 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -91,10 +91,11 @@ const DropDown = ({ required: boolean, ) => { // Translate - // Translating is expensive, skip it if it is not required - if (!skipTranslate) { - unformattedOptions = unformattedOptions.map(option => ({ ...option, label: t(option.label as ParseKeys) })); - } + // Translating is expensive, skip it if it is not required. + // Either way, copy the array so the input is not transmuted later. + unformattedOptions = skipTranslate + ? [...unformattedOptions] + : unformattedOptions.map(option => ({ ...option, label: t(option.label as ParseKeys) })); // Add "No value" option if (!required) { From 4fb640213db74cf32f81d91b8b39037e0c4b9897 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 20 Aug 2026 13:08:38 +0200 Subject: [PATCH 3/8] Remove unused auto check menuPlacement defaults to this anyway, no need to check here again --- src/components/shared/DropDown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 2c7d1c0481..9de3f9300b 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -217,7 +217,7 @@ const DropDown = ({ onMenuClose: () => openMenu(false), isDisabled: disabled, openMenuOnFocus: openMenuOnFocus, - menuPlacement: menuPlacement ?? "auto", + menuPlacement: menuPlacement, components: { MenuList }, }; From 181ab39fb712254b7308e639dfeb82741bf33198 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 20 Aug 2026 13:18:40 +0200 Subject: [PATCH 4/8] Make default Dropdown ref stable By using useRef instead of createRef we can avoid recreating the ref all the time. Should result in a very, very minor performance boost. --- src/components/shared/DropDown.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 9de3f9300b..41d5509283 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { dropDownSpacingTheme, @@ -20,7 +20,7 @@ export type DropDownOption = { * This component renders a dropdown menu using react-select */ const DropDown = ({ - ref = React.createRef, boolean, GroupBase>>>(), + ref, value, text, options, @@ -69,7 +69,8 @@ const DropDown = ({ }) => { const { t } = useTranslation(); - const selectRef = ref; + const internalRef = useRef, boolean, GroupBase>> | null>(null); + const selectRef = ref ?? internalRef; const style = dropDownStyle(customCSS ?? {}); From f8b467cad8824cc5c0a0fb9d9fb24ddecfaeb999 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 20 Aug 2026 13:32:14 +0200 Subject: [PATCH 5/8] Keep virtualization components more stable Avoid potential rerenders for virtualized options which can end up quite costly for larger lists. Could also help with flickering and focus reset issues somewhat. --- src/components/shared/DropDown.tsx | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 41d5509283..823d536452 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { dropDownSpacingTheme, @@ -16,6 +16,17 @@ export type DropDownOption = { order?: number } +function MenuListRow({ + index, + names, + style, +}: RowComponentProps<{ + names: string[]; +}>) { + const name = names[index]; + return
{name}
; +} + /** * This component renders a dropdown menu using react-select */ @@ -132,7 +143,7 @@ const DropDown = ({ /** * Custom component for list virtualization */ - const MenuList = (props: MenuListProps, false>) => { + const MenuList = useCallback((props: MenuListProps, false>) => { const { children, maxHeight } = props; return Array.isArray(children) ? ( @@ -151,18 +162,7 @@ const DropDown = ({ /> ) : null; - }; - - function MenuListRow({ - index, - names, - style, - }: RowComponentProps<{ - names: string[]; - }>) { - const name = names[index]; - return
{name}
; - } + }, [itemHeight]); const filterOptions = (inputValue: string) => { if (options) { From b425501601aff451c0e0a6a67a942e9575de3bf4 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 20 Aug 2026 13:47:04 +0200 Subject: [PATCH 6/8] Properly debounce async option load Previous code was just delaying the fetch by a second. It would still fire all accumulated request anyway (e.g. requests for "C", "Co, "Cou", "Cour", "Cours" and "Course", instead of just "Course". Implements debouncing, which should make for less load and cleaner rendering steps when searching in an async dropdown. --- src/components/shared/DropDown.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 823d536452..be062e5c5b 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -173,14 +173,23 @@ const DropDown = ({ return []; }; + const debounceTimeoutRef = useRef>(undefined); + + useEffect(() => { + return () => clearTimeout(debounceTimeoutRef.current); + }, []); + const loadOptionsAsync = (inputValue: string, callback: (options: DropDownOption[]) => void) => { - const timeout = async () => { - callback(formatOptions( - fetchOptions ? await fetchOptions(inputValue) : filterOptions(inputValue), - required, - )); - }; - setTimeout(() => { timeout(); }, 1000); + clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = setTimeout(() => { + const timeout = async () => { + callback(formatOptions( + fetchOptions ? await fetchOptions(inputValue) : filterOptions(inputValue), + required, + )); + }; + void timeout(); + }, 1000); }; const loadOptions = ( From 751be3eb3e8301bd7f3ab22b5bef70ddc55c4204 Mon Sep 17 00:00:00 2001 From: Arnei Date: Mon, 24 Aug 2026 08:54:24 +0200 Subject: [PATCH 7/8] Reduce async search debounce from 1000ms to 300ms The 1s debounce delay predates the backend performance work on the ACL role picker (see the opencast20-side fixes for /admin-ng/acl/roles.json): when a search could take several seconds regardless, an extra second of debounce was a small fraction of the total wait. Now that a real search responds in roughly 100ms, the fixed 1s delay is the dominant, and now clearly excessive, part of the perceived latency -- pull it down to 300ms, in line with typical search-as-you-type debounce intervals, and give it a name instead of a bare literal so it's easy to find and retune later. --- src/components/shared/DropDown.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index be062e5c5b..d902251173 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -16,6 +16,9 @@ export type DropDownOption = { order?: number } +// How long to wait after the user stops typing before firing a fetchOptions() search request. +const SEARCH_DEBOUNCE_MS = 300; + function MenuListRow({ index, names, @@ -189,7 +192,7 @@ const DropDown = ({ )); }; void timeout(); - }, 1000); + }, SEARCH_DEBOUNCE_MS); }; const loadOptions = ( From 402880db87b7b062955cd3427007ea17363a2c04 Mon Sep 17 00:00:00 2001 From: Arnei Date: Tue, 1 Sep 2026 12:47:40 +0200 Subject: [PATCH 8/8] Add title tooltip in dropdowns Adds wrappers around certain react-select components that add the title attribute to them. This results in tooltips when hovering certain dropdowns or their options. The goal here is to allow users to still read labels that do not fit in the dropdown. This is supposed to help with an issue where two users had have the same name but different e-mail addresses, but the e-mail addresses were not readable so the users could not be distinguished. The tooltips are only supposed to help with edge cases. If a dropdown is too small in general, that is a different issue. --- src/components/shared/DropDown.tsx | 37 ++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index d902251173..1f83acd0d6 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -4,7 +4,14 @@ import { dropDownSpacingTheme, dropDownStyle, } from "../../utils/componentStyles"; -import { GroupBase, MenuListProps, SelectInstance } from "react-select"; +import { + components as SelectComponents, + GroupBase, + MenuListProps, + OptionProps, + SelectInstance, + ValueContainerProps, +} from "react-select"; import { ParseKeys } from "i18next"; import { List, RowComponentProps } from "react-window"; import AsyncSelect, { AsyncProps } from "react-select/async"; @@ -142,6 +149,28 @@ const DropDown = ({ return unformattedOptions; }; + /** + * Wrapper that adds the title attribute to options, which should result + * in a native tooltip that is intended to help with very long labels. + */ + const OptionWithTitle = useCallback(( + props: OptionProps, boolean, GroupBase>>, + ) => ( + + ), []); + + /** + * Same wrapper as above, but for the input field. + */ + const ValueContainerWithTitle = useCallback(( + props: ValueContainerProps, boolean, GroupBase>>, + ) => ( + + ), []); + const itemHeight = optionHeight; /** * Custom component for list virtualization @@ -231,7 +260,11 @@ const DropDown = ({ isDisabled: disabled, openMenuOnFocus: openMenuOnFocus, menuPlacement: menuPlacement, - components: { MenuList }, + components: { + MenuList, + Option: OptionWithTitle, + ValueContainer: ValueContainerWithTitle, + }, }; return creatable ? (