Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export function EnsemblePicker(props: EnsemblePickerProps): JSX.Element {
<TagPicker
selection={selectedArray}
tagOptions={optionsArray}
inputProps={{
// Makes input size match tag height
className: "border border-transparent py-1",
}}
onChange={handleSelectionChange}
renderTag={(props) => (
<EnsembleTag
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function EnsembleTag(props: EnsembleTagProps): React.ReactNode {
return (
<li
className={resolveClassNames(
`text-sm rounded pl-1 pr-1 py-1 border-1 flex gap-1 items-center relative ${TAG_BACKGROUND_COLOR}`,
`text-sm rounded pl-1 pr-1 py-1 border-1 flex gap-1 items-center relative overflow-x-hidden ${TAG_BACKGROUND_COLOR}`,
{
"outline-1": props.focused,
},
Expand All @@ -46,7 +46,9 @@ export function EnsembleTag(props: EnsembleTagProps): React.ReactNode {
badgeClassName={TAG_BACKGROUND_COLOR}
/>
)}
<span>{props.label ?? String(props.tag)}</span>
<Tooltip title={props.label ?? props.tag} enterDelay="long">
<span className="truncate whitespace-normal">{props.label ?? String(props.tag)}</span>
</Tooltip>
<Tooltip title="Remove ensemble" enterDelay="medium">
<IconButton className="align-text-bottom" size="small" onClick={props.onRemove}>
<Close fontSize="inherit" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ export function DefaultTag(props: TagProps): React.ReactNode {
return (
<li
className={resolveClassNames(
"text-sm rounded pl-2 pr-1 py-0.5 bg-blue-200 flex gap-1 items-center relative",
"text-sm rounded pl-2 pr-1 py-0.5 bg-blue-200 flex gap-1 items-center relative overflow-x-hidden",
{
"outline-1 outline-blue-500": props.focused,
},
)}
onClick={props.onFocus}
>
<span>{props.label ?? String(props.tag)}</span>
<span className="truncate whitespace-normal">{props.label ?? String(props.tag)}</span>
<IconButton className="align-text-bottom" title="Remove tag" size="small" onClick={props.onRemove}>
<Close fontSize="inherit" />
</IconButton>
Expand Down
22 changes: 17 additions & 5 deletions frontend/src/lib/components/TagInput/tagInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { inRange, omit } from "lodash";
import { Key } from "ts-key-enum";

import { Tooltip } from "@lib/components/Tooltip";
import { resolveClassNames } from "@lib/utils/resolveClassNames";

import { IconButton } from "../IconButton";

Expand Down Expand Up @@ -63,6 +64,11 @@ export type TagInputProps = {
*/
alwaysShowPlaceholder?: boolean;

/**
* Forces the input to show it's focused stylization
*/
showAsFocused?: boolean;

/**
* Validates a tag being added. If false is returned, the tag will not be added
* @param tag A string tag
Expand Down Expand Up @@ -362,11 +368,17 @@ function TagInputComponent(props: TagInputProps, ref: React.ForwardedRef<HTMLDiv
<>
<div
ref={ref}
className="input-comp flex items-center gap-1 bg-white border border-gray-300 px-2 py-1.5 rounded focus-within:outline focus-within:outline-blue-500"
className={resolveClassNames(
"input-comp flex items-center gap-1 bg-white border border-gray-300 px-2 py-1.5 rounded focus-within:outline outline-blue-500",
{
outline: props.showAsFocused,
},
)}
onBlur={onRootBlur}
onClick={() => innerInputRef.current?.focus()}
>
<ul
className="grow flex gap-1 flex-wrap min-w-0 "
className="grow flex gap-1 flex-wrap min-w-0"
tabIndex={-1}
onFocus={() => innerInputRef.current?.focus()}
onCopy={copySelectedTags}
Expand All @@ -390,11 +402,11 @@ function TagInputComponent(props: TagInputProps, ref: React.ForwardedRef<HTMLDiv
})}
</React.Fragment>
))}
<li className="relative grow flex -my-1 overflow-hidden">
<li className="relative grow flex overflow-hidden">
{/* Invisible spacer-element. Used to have the input wrap as it's value grows */}
{/* ! Classes that affect size should be present in both this and the input */}
<span
className={`--input-sizer invisible pointer-events-none select-none grow max-w-full py-1 ${props.inputProps?.className}`}
className={`--input-sizer invisible pointer-events-none select-none grow max-w-full min-w-2 ${props.inputProps?.className ?? ""}`}
aria-hidden
>
{inputValue}
Expand All @@ -405,7 +417,7 @@ function TagInputComponent(props: TagInputProps, ref: React.ForwardedRef<HTMLDiv
ref={innerInputRef}
placeholder={inputPlaceholder}
{...omit(props.inputProps, "onValueChange")}
className={`absolute inset-0 py-1 outline-none min-w-0 ${props.inputProps?.className}`}
className={`absolute inset-0 outline-none min-w-0 ${props.inputProps?.className ?? ""}`}
value={inputValue}
// ! Each listener here should emit the event up
onChange={handleInputChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export function DefaultTagOption(props: TagOptionProps): React.ReactNode {
style={{ height: props.height }}
onMouseMove={props.onHover}
>
<label className="flex size-full px-2 py-1 text-gray-900 cursor-pointer gap-2">
<label className="flex size-full px-2 py-1 text-gray-900 cursor-pointer gap-2 overflow-x-hidden">
<Checkbox className="w-full" checked={props.isSelected} onChange={props.onToggle} />
{props.label ?? props.value}
<span className="truncate">{props.label ?? props.value}</span>
</label>
</li>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ import { resolveClassNames } from "@lib/utils/resolveClassNames";

import { Virtualization } from "../../Virtualization";

const DEFAULT_RECT_MIN_WIDTH = 120;
const BORDER_WIDTH = 1;

export type ItemFocusMode = "keyboard" | "mouse";

export type DropdownItemListProps<T> = {
anchorElRef: React.RefObject<HTMLElement>;
items: T[];
emptyListText: string;
optionHeight: number;
dropdownMaxHeight: number;
itemFocusIndex?: number;
minWidth?: number;
itemFocusMode?: ItemFocusMode;
renderItem?: (item: T, index: number) => React.ReactNode;
};

Expand Down Expand Up @@ -49,18 +56,18 @@ export function DropdownItemListComponent<T>(

const virtualizerStartIndex = React.useMemo(() => {
if (props.itemFocusIndex === undefined) return;
if (props.itemFocusMode === "mouse") return;
if (!innerDropdownRef.current) return;

const virtualizationTopIndex = Math.round(innerDropdownRef.current.scrollTop / props.optionHeight);
const virtualizationBottomIndex =
virtualizationTopIndex + (props.dropdownMaxHeight - 8) / props.optionHeight - 1;
const virtualizationBottomIndex = virtualizationTopIndex + props.dropdownMaxHeight / props.optionHeight - 1;

if (props.itemFocusIndex < virtualizationTopIndex) {
return props.itemFocusIndex;
} else if (props.itemFocusIndex >= virtualizationBottomIndex) {
return Math.max(0, props.itemFocusIndex - (props.dropdownMaxHeight - 8) / props.optionHeight + 1);
return Math.max(0, props.itemFocusIndex - props.dropdownMaxHeight / props.optionHeight + 1);
}
}, [props.dropdownMaxHeight, props.itemFocusIndex, props.optionHeight]);
}, [props.itemFocusIndex, props.itemFocusMode, props.optionHeight, props.dropdownMaxHeight]);

React.useLayoutEffect(
function computeDropdownRectEffect() {
Expand All @@ -70,12 +77,12 @@ export function DropdownItemListComponent<T>(
const listLength = Math.max(props.items.length * props.optionHeight, props.optionHeight);
let isFlipped = false;

// 9 added to accommodate for border + padding in the list container
const dropdownHeight = Math.min(listLength + 9, props.dropdownMaxHeight);
const dropdownHeight = Math.min(listLength, props.dropdownMaxHeight);

const newDropdownRect: Partial<DropdownRect> = {
minWidth: anchorRect.width,
height: dropdownHeight,
const newDropdownRect: DropdownRect = {
minWidth: props.minWidth ?? DEFAULT_RECT_MIN_WIDTH,
width: anchorRect.width,
height: dropdownHeight + BORDER_WIDTH,
};

const anchorTop = anchorRect.y;
Expand All @@ -91,10 +98,8 @@ export function DropdownItemListComponent<T>(
} else {
// If neither has space, put it below, but squish the height to fit
newDropdownRect.top = anchorRect.y + anchorRect.height;
newDropdownRect.height = Math.min(
dropdownHeight,
window.innerHeight - anchorRect.y - anchorRect.height - 4,
);
newDropdownRect.height =
Math.min(dropdownHeight, window.innerHeight - anchorRect.y - anchorRect.height) + BORDER_WIDTH;
}

if (anchorRect.x + anchorRect.width > window.innerWidth / 2) {
Expand All @@ -104,21 +109,21 @@ export function DropdownItemListComponent<T>(
}

setDropdownFlipped(isFlipped);
setDropdownRect((prev) => ({ ...newDropdownRect, width: prev.width }) as DropdownRect);
setDropdownRect(newDropdownRect);
},
[anchorRect, bodyRect, props.dropdownMaxHeight, props.items.length, props.optionHeight],
[anchorRect, bodyRect, props.dropdownMaxHeight, props.items.length, props.minWidth, props.optionHeight],
);

return createPortal(
<ul
className={resolveClassNames(
"absolute bg-white border border-gray-300 rounded-md shadow-md overflow-y-auto z-50 box-border gap-1 px-2 py-1",
"absolute bg-white border border-gray-300 rounded-md shadow-md overflow-y-auto z-50 px-2",
{
"border-t-0 rounded-t-none": !dropdownFlipped,
"border-b-0 rounded-b-none": dropdownFlipped,
"border-t-0! rounded-t-none": !dropdownFlipped,
"border-b-0! rounded-b-none": dropdownFlipped,
},
)}
style={{ ...dropdownRect }}
style={{ ...dropdownRect, borderWidth: `${BORDER_WIDTH}px` }}
ref={innerDropdownRef}
>
{props.items.length === 0 && (
Expand Down
38 changes: 30 additions & 8 deletions frontend/src/lib/components/TagPicker/tagPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import { TagInput } from "../TagInput";

import { useDebouncedStateEmit, useOnScreenChangeHandler } from "./hooks";
import { DefaultTagOption, type TagOptionProps } from "./private-components/defaultTagOption";
import type { ItemFocusMode } from "./private-components/dropdownItemList";
import { DropdownItemList } from "./private-components/dropdownItemList";

const DROPDOWN_MAX_HEIGHT = 200;
const TAG_OPTION_HEIGHT = 32;
const DROPDOWN_MAX_HEIGHT = TAG_OPTION_HEIGHT * 6;

const NO_MATCHING_TAGS_TEXT = "No matching options";
const NO_TAGS_TEXT = "No options";
Expand All @@ -31,9 +32,10 @@ export type TagPickerProps<TValue extends string = string> = {
placeholder?: string;
showListAsSelectionCount?: boolean;
debounceTimeMs?: number;
dropdownMinWidth?: number;
renderTagOption?: (props: TagOptionProps) => React.ReactNode;
onChange?: (newSelection: TValue[]) => void;
} & Pick<TagInputProps, "renderTag"> &
} & Pick<TagInputProps, "renderTag" | "inputProps"> &
BaseComponentProps;

export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRef<HTMLDivElement>): React.ReactElement {
Expand All @@ -47,7 +49,9 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
// --- State variables
const [inputValue, setInputValue] = React.useState("");
const [dropdownVisible, setDropdownVisible] = React.useState<boolean>(false);
const [showInputAsFocused, setShowInputAsFocused] = React.useState(false);
const [focusedItemIndex, setFocusedItemIndex] = React.useState<number>(-1);
const [itemFocusMode, setItemFocusMode] = React.useState<ItemFocusMode>("keyboard");

const [selection, debouncedOnChange, flushDebounce] = useDebouncedStateEmit(
props.selection,
Expand Down Expand Up @@ -82,13 +86,18 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe

// Reset the dropdown item focus whenever filtered tags change, as the focused item likely went away.
if (prevFilteredTags !== filteredTags) {
const prevFocusedTag = prevFilteredTags[focusedItemIndex];
// Avoid iterating a potentially long list if no item is focused
const newFocusedIndex = prevFocusedTag ? filteredTags.findIndex((t) => t.value === prevFocusedTag.value) : -1;

setPrevFilteredTags(filteredTags);
setFocusedItemIndex(-1);
setFocusedItemIndex(newFocusedIndex);
}

// --- Callbacks
const handleInputFocus = React.useCallback(function handleInputFocus() {
setDropdownVisible(true);
setShowInputAsFocused(true);
}, []);

const handleFocusOut = React.useCallback(
Expand All @@ -97,6 +106,7 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
!tagInputRef.current?.contains(evt.relatedTarget as Node) &&
!dropdownRef.current?.contains(evt.relatedTarget as Node)
) {
setShowInputAsFocused(false);
setDropdownVisible(false);
flushDebounce();
}
Expand Down Expand Up @@ -148,7 +158,7 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
);

const handleToggleTag = React.useCallback(
function handleToggleTag(tag: string) {
function handleToggleTag(tag: string, listIndex: number) {
const newSelection = [...selection];
const tagIndex = selection.indexOf(tag);

Expand All @@ -158,6 +168,8 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
newSelection.splice(tagIndex, 1);
}

setFocusedItemIndex(listIndex);
setItemFocusMode("keyboard");
handleTagsChange(newSelection);
},
[handleTagsChange, selection],
Expand All @@ -176,9 +188,9 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
if (!dropdownVisible) {
setDropdownVisible(true);
} else if (focusedItemIndex !== -1) {
handleToggleTag(filteredTags[focusedItemIndex].value);
handleToggleTag(filteredTags[focusedItemIndex].value, focusedItemIndex);
} else if (filteredTags.length === 1) {
handleToggleTag(filteredTags[0].value);
handleToggleTag(filteredTags[0].value, 0);
} else if (filteredTags.length > 0) {
setFocusedItemIndex(0);
}
Expand All @@ -196,6 +208,7 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe

if (evt.shiftKey) selectionMove *= 10;

setItemFocusMode("keyboard");
setFocusedItemIndex((prev) => clamp(prev + selectionMove, 0, filteredTags.length - 1));
}
},
Expand All @@ -211,13 +224,19 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
}, []),
);

const handleListOptionHover = React.useCallback(function handleListOptionHover(index: number) {
setItemFocusMode("mouse");
setFocusedItemIndex(index);
}, []);

return (
<BaseComponent ref={ref} disabled={props.disabled}>
<TagInput
ref={tagInputRef}
inputRef={filterInputRef}
placeholder={tagInputPlaceholder}
tags={selection}
showAsFocused={showInputAsFocused}
alwaysShowPlaceholder={props.showListAsSelectionCount}
backspaceDeleteMode={props.showListAsSelectionCount ? "none" : "hard"}
tagListSelectionMode={props.showListAsSelectionCount ? "none" : "multiple"}
Expand All @@ -232,6 +251,7 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
onValueChange: setInputValue,
onFocus: handleInputFocus,
onKeyDown: handleInputKeyDown,
...props.inputProps,
}}
/>

Expand All @@ -242,8 +262,10 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
items={filteredTags}
optionHeight={TAG_OPTION_HEIGHT}
itemFocusIndex={focusedItemIndex}
itemFocusMode={itemFocusMode}
dropdownMaxHeight={DROPDOWN_MAX_HEIGHT}
emptyListText={props.tagOptions.length === 0 ? NO_TAGS_TEXT : NO_MATCHING_TAGS_TEXT}
minWidth={props.dropdownMinWidth}
renderItem={(option, index) => (
<React.Fragment key={index}>
{renderTagOptionOrDefault({
Expand All @@ -252,8 +274,8 @@ export function TagPickerComponent(props: TagPickerProps, ref: React.ForwardedRe
isSelected: selection.includes(option.value),
isFocused: focusedItemIndex === index,
height: TAG_OPTION_HEIGHT,
onToggle: () => handleToggleTag(option.value),
onHover: () => setFocusedItemIndex(index),
onToggle: () => handleToggleTag(option.value, index),
onHover: () => handleListOptionHover(index),
})}
</React.Fragment>
)}
Expand Down
Loading
Loading