List components #31
249
frontend/src/components/Combobox/Combobox.tsx
Normal file
249
frontend/src/components/Combobox/Combobox.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import TextInput from "../TextInput/TextInput";
|
||||||
|
import { SearchIcon } from "../Icon/Icon";
|
||||||
|
import SearchResultList from "../SearchResultList/SearchResultList";
|
||||||
|
import { useClickOutside } from "../../hooks/useClickOutside";
|
||||||
|
|
||||||
|
export type ComboboxSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
export interface ComboboxProps<T> {
|
||||||
|
options: T[];
|
||||||
|
getOptionValue: (option: T) => string;
|
||||||
|
getOptionLabel: (option: T) => string;
|
||||||
|
getOptionSubtitle?: (option: T) => string | undefined;
|
||||||
|
placeholder?: string;
|
||||||
|
label: string;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
size?: ComboboxSize;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
customWidth?: string;
|
||||||
|
dropdownHeight?: number;
|
||||||
|
noResultsText?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
||||||
|
|
|||||||
|
onSearchChange?: (term: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthClasses: Record<ComboboxSize, string> = {
|
||||||
|
sm: "w-(--text-input-default-width-md)",
|
||||||
|
md: "w-(--text-input-default-width-md)",
|
||||||
|
lg: "w-(--text-input-default-width-lg)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownWrapperClasses =
|
||||||
|
"absolute top-full left-0 z-50 w-full mt-(--spacing-sm)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the focused item into view when navigating with arrow keys.
|
||||||
|
* Uses "nearest" to minimize scrolling - only scrolls if the item is outside the visible area.
|
||||||
|
*/
|
||||||
|
function useScrollIntoView(
|
||||||
|
index: number,
|
||||||
|
refs: React.RefObject<(HTMLDivElement | null)[]>,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (index >= 0 && refs.current[index]) {
|
||||||
|
refs.current[index]?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}, [index, refs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextIndex(
|
||||||
|
currentIndex: number,
|
||||||
|
direction: "up" | "down",
|
||||||
|
maxIndex: number,
|
||||||
|
): number {
|
||||||
|
const next = currentIndex + (direction === "down" ? 1 : -1);
|
||||||
|
return Math.max(0, Math.min(next, maxIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Combobox<T>({
|
||||||
|
options,
|
||||||
|
getOptionValue,
|
||||||
|
getOptionLabel,
|
||||||
|
getOptionSubtitle,
|
||||||
|
placeholder = "Search...",
|
||||||
|
label,
|
||||||
|
hideLabel = false,
|
||||||
|
size = "md",
|
||||||
|
fullWidth = false,
|
||||||
|
customWidth,
|
||||||
|
dropdownHeight = 300,
|
||||||
|
noResultsText = "No results found",
|
||||||
|
multiple = false,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSearchChange,
|
||||||
|
}: ComboboxProps<T>) {
|
||||||
|
// Convert value (undefined | string | string[]) to always be an array
|
||||||
|
const selectedValues: string[] =
|
||||||
|
value === undefined ? [] : Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters options by matching search term against label and subtitle.
|
||||||
|
* Skipped when onSearchChange is provided (API handles filtering).
|
||||||
|
*/
|
||||||
|
const filterOptions = (options: T[], searchTerm: string): T[] => {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return options.filter((opt) => {
|
||||||
|
const label = getOptionLabel(opt).toLowerCase();
|
||||||
|
const subtitle = getOptionSubtitle?.(opt)?.toLowerCase() || "";
|
||||||
|
return label.includes(term) || subtitle.includes(term);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines what text to display in the input field.
|
||||||
|
* - If user is typing, show the search term
|
||||||
|
* - If multi-select mode, show nothing (selections appear as ListCards below)
|
||||||
|
* - If single-select with a selection, show the selected option's label
|
||||||
|
*/
|
||||||
|
const getDisplayValue = (): string => {
|
||||||
|
if (searchTerm) return searchTerm;
|
||||||
|
if (multiple) return "";
|
||||||
|
if (selectedValues.length === 0) return "";
|
||||||
|
|
||||||
|
const selected = options.find(
|
||||||
|
(opt) => getOptionValue(opt) === selectedValues[0],
|
||||||
|
);
|
||||||
|
return selected ? getOptionLabel(selected) : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derived state - skip local filtering when onSearchChange is provided (API handles filtering)
|
||||||
|
const filteredOptions = onSearchChange
|
||||||
|
? options
|
||||||
|
: filterOptions(options, searchTerm);
|
||||||
|
const displayValue = getDisplayValue();
|
||||||
|
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
|
||||||
|
useClickOutside(containerRef, closeDropdown);
|
||||||
|
useScrollIntoView(focusedIndex, itemRefs);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSearchTerm(value);
|
||||||
|
onSearchChange?.(value);
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
// Clear selection when user empties the field in single-select mode
|
||||||
|
if (!multiple && selectedValues.length > 0) {
|
||||||
|
onChange?.("");
|
||||||
|
}
|
||||||
|
closeDropdown();
|
||||||
|
} else {
|
||||||
|
setIsOpen(true);
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (option: T) => {
|
||||||
|
const optionValue = getOptionValue(option);
|
||||||
|
if (multiple) {
|
||||||
|
// Toggle selection in multi-select mode
|
||||||
|
const newValues = selectedValues.includes(optionValue)
|
||||||
|
? selectedValues.filter((v) => v !== optionValue)
|
||||||
|
: [...selectedValues, optionValue];
|
||||||
|
onChange?.(newValues);
|
||||||
|
} else {
|
||||||
|
// Replace selection in single-select mode
|
||||||
|
onChange?.(optionValue);
|
||||||
|
}
|
||||||
|
setSearchTerm(""); // Clear search so selected label shows
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
// Open dropdown on arrow keys when closed (only if we have options to show)
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
if (filteredOptions.length > 0) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle navigation when open
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedIndex((prev) =>
|
||||||
|
getNextIndex(prev, "down", filteredOptions.length - 1),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedIndex((prev) =>
|
||||||
|
getNextIndex(prev, "up", filteredOptions.length - 1),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
||||||
|
handleSelect(filteredOptions[focusedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Escape":
|
||||||
|
case "Tab":
|
||||||
|
closeDropdown();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerClasses = clsx(
|
||||||
|
"relative",
|
||||||
|
fullWidth && "w-full",
|
||||||
|
!fullWidth && !customWidth && widthClasses[size],
|
||||||
|
);
|
||||||
|
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={containerClasses} style={widthStyle}>
|
||||||
|
<TextInput
|
||||||
|
value={displayValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onBlur={closeDropdown}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
label={label}
|
||||||
|
hideLabel={hideLabel}
|
||||||
|
size={size}
|
||||||
|
fullWidth={fullWidth || !!customWidth}
|
||||||
|
customWidth={customWidth}
|
||||||
|
Icon={SearchIcon}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className={dropdownWrapperClasses}>
|
||||||
|
<SearchResultList
|
||||||
|
options={filteredOptions}
|
||||||
|
getOptionValue={getOptionValue}
|
||||||
|
getOptionLabel={getOptionLabel}
|
||||||
|
getOptionSubtitle={getOptionSubtitle}
|
||||||
|
selectedValues={selectedValues}
|
||||||
|
focusedIndex={focusedIndex}
|
||||||
|
maxHeight={dropdownHeight}
|
||||||
|
noResultsText={noResultsText}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
itemRefs={itemRefs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/ListCard/ListCard.tsx
Normal file
70
frontend/src/components/ListCard/ListCard.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { RemoveIcon } from "../Icon/Icon";
|
||||||
|
|
||||||
|
export interface ListCardProps
|
||||||
|
extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onRemove?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses = clsx(
|
||||||
|
"px-(--padding-md) py-(--padding-md)",
|
||||||
|
"bg-sky-35 border border-sky-100 rounded-(--border-radius-md)",
|
||||||
|
"flex items-center justify-between",
|
||||||
|
"group cursor-pointer",
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateClasses = clsx(
|
||||||
|
"text-base-ink-strong",
|
||||||
|
"hover:bg-sky-70 hover:text-base-ink-max",
|
||||||
|
"focus-visible:text-base-ink-max",
|
||||||
|
"focus-visible:border-primary focus-visible:border-[length:var(--border-width-sm)]",
|
||||||
|
"focus-visible:outline focus-visible:outline-sky-100 focus-visible:outline-[length:var(--border-width-lg)]",
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeButtonClasses = clsx(
|
||||||
|
"shrink-0 ml-(--spacing-sm) cursor-pointer",
|
||||||
|
"text-base-ink-placeholder",
|
||||||
|
"group-hover:text-base-ink-max group-focus-visible:text-base-ink-max",
|
||||||
|
"focus-visible:outline-none",
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ListCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onRemove,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ListCardProps) {
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && onRemove) {
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(baseClasses, stateClasses, className)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="body-light-sm">{title}</div>
|
||||||
|
{subtitle && <div className="body-light-sm">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={onRemove}
|
||||||
|
className={removeButtonClasses}
|
||||||
|
>
|
||||||
|
<RemoveIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/components/ListItem/ListItem.tsx
Normal file
68
frontend/src/components/ListItem/ListItem.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { CheckmarkIcon } from "../Icon/Icon";
|
||||||
|
|
||||||
|
export interface ListItemProps
|
||||||
|
extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses =
|
||||||
|
"w-full px-(--padding-md) py-(--padding-md) cursor-pointer flex items-center justify-between";
|
||||||
|
|
||||||
|
const defaultStateClasses = clsx(
|
||||||
|
"bg-base-canvas text-base-ink-strong",
|
||||||
|
"hover:bg-sky-100 hover:text-base-ink-max",
|
||||||
|
"focus-visible:bg-sky-100 focus-visible:text-primary focus-visible:outline-none",
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedStateClasses = clsx(
|
||||||
|
"bg-base-canvas text-base-ink-placeholder",
|
||||||
|
"hover:bg-sky-100 hover:text-base-ink-max",
|
||||||
|
"focus-visible:bg-sky-100 focus-visible:text-primary focus-visible:outline-none",
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusedStateClasses = "bg-sky-100 text-base-ink-max";
|
||||||
|
|
||||||
|
function getStateClasses(selected: boolean, focused: boolean): string {
|
||||||
|
if (selected && focused) return focusedStateClasses;
|
||||||
|
if (selected) return selectedStateClasses;
|
||||||
|
if (focused) return focusedStateClasses;
|
||||||
|
return defaultStateClasses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListItem({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
selected = false,
|
||||||
|
focused = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ListItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
baseClasses,
|
||||||
|
getStateClasses(selected, focused),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
tabIndex={-1}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="body-normal-md">{title}</div>
|
||||||
|
{subtitle && <div className="body-light-sm">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<div className="shrink-0 ml-(--spacing-sm)">
|
||||||
|
<CheckmarkIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Combobox, { type ComboboxSize } from "../Combobox/Combobox";
|
||||||
|
import ListCard from "../ListCard/ListCard";
|
||||||
|
|
||||||
|
export interface ParticipantPickerProps<T>
|
||||||
|
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
||||||
|
options: T[];
|
||||||
|
getOptionValue: (option: T) => string;
|
||||||
|
getOptionLabel: (option: T) => string;
|
||||||
|
getOptionSubtitle?: (option: T) => string | undefined;
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
label: string;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
noResultsText?: string;
|
||||||
|
size?: ComboboxSize;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
customWidth?: string;
|
||||||
|
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
||||||
|
onSearchChange?: (term: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthClasses: Record<ComboboxSize, string> = {
|
||||||
|
sm: "w-(--text-input-default-width-md)",
|
||||||
|
md: "w-(--text-input-default-width-md)",
|
||||||
|
lg: "w-(--text-input-default-width-lg)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ParticipantPicker<T>({
|
||||||
|
options,
|
||||||
|
getOptionValue,
|
||||||
|
getOptionLabel,
|
||||||
|
getOptionSubtitle,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Sök...",
|
||||||
|
label,
|
||||||
|
hideLabel = false,
|
||||||
|
noResultsText = "Inga resultat",
|
||||||
|
size = "md",
|
||||||
|
fullWidth = false,
|
||||||
|
customWidth,
|
||||||
|
onSearchChange,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: ParticipantPickerProps<T>) {
|
||||||
|
const handleRemove = (valueToRemove: string) => {
|
||||||
|
onChange(value.filter((v) => v !== valueToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedOptions = options.filter((opt) =>
|
||||||
|
value.includes(getOptionValue(opt)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerClasses = clsx(
|
||||||
|
"flex flex-col gap-(--spacing-sm)",
|
||||||
|
fullWidth && "w-full",
|
||||||
|
!fullWidth && !customWidth && widthClasses[size],
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
const widthStyle = customWidth ? { width: customWidth, ...style } : style;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses} style={widthStyle} {...props}>
|
||||||
|
<Combobox
|
||||||
|
options={options}
|
||||||
|
getOptionValue={getOptionValue}
|
||||||
|
getOptionLabel={getOptionLabel}
|
||||||
|
getOptionSubtitle={getOptionSubtitle}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange(v as string[])}
|
||||||
|
placeholder={placeholder}
|
||||||
|
label={label}
|
||||||
|
hideLabel={hideLabel}
|
||||||
|
noResultsText={noResultsText}
|
||||||
|
size={size}
|
||||||
|
fullWidth
|
||||||
|
multiple
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
/>
|
||||||
|
{selectedOptions.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-(--spacing-sm)">
|
||||||
|
{selectedOptions.map((option) => (
|
||||||
|
<ListCard
|
||||||
|
key={getOptionValue(option)}
|
||||||
|
title={getOptionLabel(option)}
|
||||||
|
subtitle={getOptionSubtitle?.(option)}
|
||||||
|
onRemove={() => handleRemove(getOptionValue(option))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import ListItem from "../ListItem/ListItem";
|
||||||
|
|
||||||
|
export interface SearchResultListProps<T>
|
||||||
|
extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
||||||
|
ansv7779 marked this conversation as resolved
ansv7779
commented
This is limiting. Often times you want to select some more complex object as you do in your examples in This is limiting. Often times you want to select some more complex object as you do in your examples in `ComponentLibrary`. Forcing all users of the component to do their own lookup when it should be handled by the `Combobox`/this component.
|
|||||||
|
options: T[];
|
||||||
|
getOptionValue: (option: T) => string;
|
||||||
|
getOptionLabel: (option: T) => string;
|
||||||
|
getOptionSubtitle?: (option: T) => string | undefined;
|
||||||
|
selectedValues?: string[];
|
||||||
|
focusedIndex?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
noResultsText?: string;
|
||||||
|
onSelect?: (option: T) => void;
|
||||||
|
itemRefs?: React.RefObject<(HTMLDivElement | null)[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses = clsx(
|
||||||
|
"w-full bg-base-canvas",
|
||||||
|
"border border-base-ink-medium rounded-(--border-radius-md)",
|
||||||
|
"overflow-y-auto",
|
||||||
|
);
|
||||||
|
|
||||||
|
const noResultsClasses =
|
||||||
|
"px-(--padding-md) py-(--padding-md) body-normal-md text-base-ink-placeholder text-center";
|
||||||
|
|
||||||
|
const dividerClasses =
|
||||||
|
"border-t border-base-ink-soft [border-top-width:var(--border-width-sm)]";
|
||||||
|
|
||||||
|
export default function SearchResultList<T>({
|
||||||
|
options,
|
||||||
|
getOptionValue,
|
||||||
|
getOptionLabel,
|
||||||
|
getOptionSubtitle,
|
||||||
|
selectedValues = [],
|
||||||
|
focusedIndex = -1,
|
||||||
|
maxHeight = 300,
|
||||||
|
noResultsText = "No results found",
|
||||||
|
onSelect,
|
||||||
|
itemRefs,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: SearchResultListProps<T>) {
|
||||||
|
const isSelected = (option: T) =>
|
||||||
|
selectedValues.includes(getOptionValue(option));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(baseClasses, className)}
|
||||||
|
style={{ maxHeight, ...style }}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.length > 0 ? (
|
||||||
|
options.map((option, index) => (
|
||||||
|
<div
|
||||||
|
key={getOptionValue(option)}
|
||||||
|
ref={(el) => {
|
||||||
|
if (itemRefs?.current) {
|
||||||
|
itemRefs.current[index] = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={index > 0 ? dividerClasses : undefined}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={getOptionLabel(option)}
|
||||||
|
subtitle={getOptionSubtitle?.(option)}
|
||||||
|
selected={isSelected(option)}
|
||||||
|
focused={index === focusedIndex}
|
||||||
|
onClick={() => onSelect?.(option)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={noResultsClasses}>{noResultsText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/hooks/useClickOutside.ts
Normal file
17
frontend/src/hooks/useClickOutside.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function useClickOutside(
|
||||||
|
ref: React.RefObject<HTMLElement | null>,
|
||||||
|
onClickOutside: () => void,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
onClickOutside();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [ref, onClickOutside]);
|
||||||
|
}
|
||||||
@ -2,11 +2,38 @@ import { useState, useEffect } from "react";
|
|||||||
import Button from "../components/Button/Button";
|
import Button from "../components/Button/Button";
|
||||||
import TextInput from "../components/TextInput/TextInput";
|
import TextInput from "../components/TextInput/TextInput";
|
||||||
import { SearchIcon } from "../components/Icon/Icon";
|
import { SearchIcon } from "../components/Icon/Icon";
|
||||||
|
import ListItem from "../components/ListItem/ListItem";
|
||||||
|
import SearchResultList from "../components/SearchResultList/SearchResultList";
|
||||||
|
import Combobox from "../components/Combobox/Combobox";
|
||||||
|
import ListCard from "../components/ListCard/ListCard";
|
||||||
|
import ParticipantPicker from "../components/ParticipantPicker/ParticipantPicker";
|
||||||
|
|
||||||
|
interface Person {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peopleOptions: Person[] = [
|
||||||
|
{ value: "1", label: "Lennart Johansson", subtitle: "lejo1891" },
|
||||||
|
{ value: "2", label: "Mats Rubarth", subtitle: "matsrub1891" },
|
||||||
|
{ value: "3", label: "Daniel Tjernström", subtitle: "datj1891" },
|
||||||
|
{ value: "4", label: "Johan Mjällby", subtitle: "jomj1891" },
|
||||||
|
{ value: "5", label: "Krister Nordin", subtitle: "krno1891" },
|
||||||
|
{ value: "6", label: "Kurre Hamrin", subtitle: "kuha1891" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getPersonValue = (person: Person) => person.value;
|
||||||
|
const getPersonLabel = (person: Person) => person.label;
|
||||||
|
const getPersonSubtitle = (person: Person) => person.subtitle;
|
||||||
|
|
||||||
export default function ComponentLibrary() {
|
export default function ComponentLibrary() {
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
return document.documentElement.classList.contains("dark");
|
return document.documentElement.classList.contains("dark");
|
||||||
});
|
});
|
||||||
|
const [selectedPerson, setSelectedPerson] = useState<string>("");
|
||||||
|
const [selectedPeople, setSelectedPeople] = useState<string[]>([]);
|
||||||
|
const [participants, setParticipants] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (darkMode) {
|
if (darkMode) {
|
||||||
@ -45,13 +72,27 @@ export default function ComponentLibrary() {
|
|||||||
<Button size="lg">Large</Button>
|
<Button size="lg">Large</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-lg">
|
<section className="mt-lg">
|
||||||
<h2 className="mb-md">Text Input Sizes</h2>
|
<h2 className="mb-md">Text Input Sizes</h2>
|
||||||
<div className="flex flex-wrap items-center gap-md">
|
<div className="flex flex-wrap items-center gap-md">
|
||||||
<TextInput size="sm" placeholder="Small" label="Small input" hideLabel />
|
<TextInput
|
||||||
<TextInput size="md" placeholder="Medium" label="Medium input" hideLabel />
|
size="sm"
|
||||||
<TextInput size="lg" placeholder="Large" label="Large input" hideLabel />
|
placeholder="Small"
|
||||||
|
label="Small input"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
size="md"
|
||||||
|
placeholder="Medium"
|
||||||
|
label="Medium input"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
size="lg"
|
||||||
|
placeholder="Large"
|
||||||
|
label="Large input"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -86,14 +127,23 @@ export default function ComponentLibrary() {
|
|||||||
<h2 className="mb-md">Text Input States</h2>
|
<h2 className="mb-md">Text Input States</h2>
|
||||||
<div className="flex flex-wrap items-center gap-md">
|
<div className="flex flex-wrap items-center gap-md">
|
||||||
<TextInput placeholder="Default" label="Default state" hideLabel />
|
<TextInput placeholder="Default" label="Default state" hideLabel />
|
||||||
<TextInput placeholder="Error state" error label="Error state" hideLabel />
|
<TextInput
|
||||||
|
placeholder="Error state"
|
||||||
|
error
|
||||||
|
label="Error state"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-lg">
|
<section className="mt-lg">
|
||||||
<h2 className="mb-md">Text Input With/Without Placeholder</h2>
|
<h2 className="mb-md">Text Input With/Without Placeholder</h2>
|
||||||
<div className="flex flex-wrap items-center gap-md">
|
<div className="flex flex-wrap items-center gap-md">
|
||||||
<TextInput placeholder="Placeholder" label="With placeholder" hideLabel />
|
<TextInput
|
||||||
|
placeholder="Placeholder"
|
||||||
|
label="With placeholder"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
<TextInput label="Without placeholder" hideLabel />
|
<TextInput label="Without placeholder" hideLabel />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -101,8 +151,18 @@ export default function ComponentLibrary() {
|
|||||||
<section className="mt-lg">
|
<section className="mt-lg">
|
||||||
<h2 className="mb-md">Text Input Width Options</h2>
|
<h2 className="mb-md">Text Input Width Options</h2>
|
||||||
<div className="flex flex-col gap-md">
|
<div className="flex flex-col gap-md">
|
||||||
<TextInput placeholder="Full width" fullWidth label="Full width input" hideLabel />
|
<TextInput
|
||||||
<TextInput placeholder="Custom width" customWidth="300px" label="Custom width input" hideLabel />
|
placeholder="Full width"
|
||||||
|
fullWidth
|
||||||
|
label="Full width input"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Custom width"
|
||||||
|
customWidth="300px"
|
||||||
|
label="Custom width input"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -131,6 +191,266 @@ export default function ComponentLibrary() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">List Item</h2>
|
||||||
|
<div className="max-w-96 border border-base-ink-soft rounded-(--border-radius-md) overflow-hidden">
|
||||||
|
<ListItem title="Lennart Johansson" subtitle="lejo1891" />
|
||||||
|
<ListItem title="Mats Rubarth" subtitle="matsrub1891" />
|
||||||
|
<ListItem title="Daniel Tjernström" subtitle="datj1891" selected />
|
||||||
|
<ListItem title="Johan Mjällby" subtitle="jomj1891" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">List Item - Title Only</h2>
|
||||||
|
<div className="max-w-96 border border-base-ink-soft rounded-(--border-radius-md) overflow-hidden">
|
||||||
|
<ListItem title="Krister Nordin" />
|
||||||
|
<ListItem title="Kurre Hamrin" selected />
|
||||||
|
<ListItem title="Per Karlsson" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">SearchResultList</h2>
|
||||||
|
<div className="max-w-96">
|
||||||
|
<SearchResultList
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
selectedValues={["3"]}
|
||||||
|
focusedIndex={1}
|
||||||
|
noResultsText="Inga resultat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">SearchResultList - Empty</h2>
|
||||||
|
<div className="max-w-96">
|
||||||
|
<SearchResultList
|
||||||
|
options={[]}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
noResultsText="Inga resultat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Combobox - Single Select</h2>
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
value={selectedPerson}
|
||||||
|
onChange={(v) => setSelectedPerson(v as string)}
|
||||||
|
placeholder="Sök..."
|
||||||
|
label="Välj person"
|
||||||
|
/>
|
||||||
|
<p className="body-light-sm text-base-ink-placeholder">
|
||||||
|
Selected:{" "}
|
||||||
|
{selectedPerson
|
||||||
|
? peopleOptions.find((p) => p.value === selectedPerson)?.label
|
||||||
|
: "None"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Combobox - Multi Select</h2>
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
value={selectedPeople}
|
||||||
|
onChange={(v) => setSelectedPeople(v as string[])}
|
||||||
|
placeholder="Sök..."
|
||||||
|
label="Välj personer"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
<p className="body-light-sm text-base-ink-placeholder">
|
||||||
|
Selected:{" "}
|
||||||
|
{selectedPeople.length > 0
|
||||||
|
? selectedPeople
|
||||||
|
.map((v) => peopleOptions.find((p) => p.value === v)?.label)
|
||||||
|
.join(", ")
|
||||||
|
: "None"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Combobox - Sizes</h2>
|
||||||
|
<div className="flex flex-wrap items-start gap-md">
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
placeholder="Small"
|
||||||
|
size="sm"
|
||||||
|
label="Small"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
placeholder="Medium"
|
||||||
|
size="md"
|
||||||
|
label="Medium"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
placeholder="Large"
|
||||||
|
size="lg"
|
||||||
|
label="Large"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Combobox - Custom Width</h2>
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
placeholder="Sök..."
|
||||||
|
customWidth="350px"
|
||||||
|
label="Custom width"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
placeholder="Sök..."
|
||||||
|
label="Full width"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">ListCard</h2>
|
||||||
|
<div className="flex flex-col gap-md max-w-96">
|
||||||
|
<ListCard title="Lennart Johansson" onRemove={() => {}} />
|
||||||
|
<ListCard title="Mats Rubarth" onRemove={() => {}} />
|
||||||
|
<ListCard title="Daniel Tjernström" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">ParticipantPicker</h2>
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Sök deltagare..."
|
||||||
|
label="Välj deltagare"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">ParticipantPicker - Sizes</h2>
|
||||||
|
<div className="flex flex-wrap items-start gap-md">
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Small"
|
||||||
|
size="sm"
|
||||||
|
label="Small"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Medium"
|
||||||
|
size="md"
|
||||||
|
label="Medium"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Large"
|
||||||
|
size="lg"
|
||||||
|
label="Large"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">ParticipantPicker - Custom Width</h2>
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Sök..."
|
||||||
|
customWidth="350px"
|
||||||
|
label="Custom width"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Sök..."
|
||||||
|
label="Full width"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@ -7,6 +7,7 @@ menu.main {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
z-index: 40;
|
||||||
li {
|
li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user
I think this component would have been better split into two, a single select and a multiple select version. If I use it with
multiple={false}I would not want myonChangecallback to be called with an array of choices.