List components #31

Merged
stne3960 merged 68 commits from list_item into main 2025-12-18 12:41:13 +01:00
4 changed files with 148 additions and 87 deletions
Showing only changes of commit bbe1012b80 - Show all commits

View File

@ -2,17 +2,16 @@ import { useState, useRef, useEffect } from "react";
import clsx from "clsx"; import clsx from "clsx";
import TextInput from "../TextInput/TextInput"; import TextInput from "../TextInput/TextInput";
import { SearchIcon } from "../Icon/Icon"; import { SearchIcon } from "../Icon/Icon";
import SearchResultList, { import SearchResultList from "../SearchResultList/SearchResultList";
type SearchResultOption,
} from "../SearchResultList/SearchResultList";
import { useClickOutside } from "../../hooks/useClickOutside"; import { useClickOutside } from "../../hooks/useClickOutside";
export type ComboboxOption = SearchResultOption;
export type ComboboxSize = "sm" | "md" | "lg"; export type ComboboxSize = "sm" | "md" | "lg";
export interface ComboboxProps { export interface ComboboxProps<T> {
options: ComboboxOption[]; options: T[];
getOptionValue: (option: T) => string;
getOptionLabel: (option: T) => string;
getOptionSubtitle?: (option: T) => string | undefined;
placeholder?: string; placeholder?: string;
label: string; label: string;
hideLabel?: boolean; hideLabel?: boolean;
@ -52,18 +51,6 @@ function useScrollIntoView(
}, [index, refs]); }, [index, refs]);
} }
function filterOptions(
options: ComboboxOption[],
searchTerm: string,
): ComboboxOption[] {
const term = searchTerm.toLowerCase();
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(term) ||
opt.subtitle?.toLowerCase().includes(term),
);
}
function getNextIndex( function getNextIndex(
currentIndex: number, currentIndex: number,
direction: "up" | "down", direction: "up" | "down",
@ -73,31 +60,11 @@ function getNextIndex(
return Math.max(0, Math.min(next, maxIndex)); return Math.max(0, Math.min(next, maxIndex));
} }
/** export default function Combobox<T>({
* 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
*/
function getDisplayValue(
options: ComboboxOption[],
selectedValues: string[],
multiple: boolean,
searchTerm: string,
): string {
if (searchTerm) return searchTerm;
if (multiple) {
return "";
}
if (selectedValues.length === 0) return "";
return options.find((opt) => opt.value === selectedValues[0])?.label || "";
}
export default function Combobox({
options, options,
getOptionValue,
getOptionLabel,
getOptionSubtitle,
placeholder = "Search...", placeholder = "Search...",
label, label,
hideLabel = false, hideLabel = false,
@ -110,7 +77,7 @@ export default function Combobox({
value, value,
onChange, onChange,
onSearchChange, onSearchChange,
}: ComboboxProps) { }: ComboboxProps<T>) {
// Convert value (undefined | string | string[]) to always be an array // Convert value (undefined | string | string[]) to always be an array
const selectedValues: string[] = const selectedValues: string[] =
value === undefined ? [] : Array.isArray(value) ? value : [value]; value === undefined ? [] : Array.isArray(value) ? value : [value];
@ -122,23 +89,47 @@ export default function Combobox({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = 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) // Derived state - skip local filtering when onSearchChange is provided (API handles filtering)
const filteredOptions = onSearchChange const filteredOptions = onSearchChange
? options ? options
: filterOptions(options, searchTerm); : filterOptions(options, searchTerm);
const displayValue = getDisplayValue( const displayValue = getDisplayValue();
options,
selectedValues,
multiple,
searchTerm,
);
const closeDropdown = () => setIsOpen(false); const closeDropdown = () => setIsOpen(false);
useClickOutside(containerRef, closeDropdown); useClickOutside(containerRef, closeDropdown);
useScrollIntoView(focusedIndex, itemRefs); useScrollIntoView(focusedIndex, itemRefs);
// Event handlers
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
setSearchTerm(value); setSearchTerm(value);
@ -156,16 +147,17 @@ export default function Combobox({
} }
}; };
const handleSelect = (option: ComboboxOption) => { const handleSelect = (option: T) => {
const optionValue = getOptionValue(option);
if (multiple) { if (multiple) {
// Toggle selection in multi-select mode // Toggle selection in multi-select mode
const newValues = selectedValues.includes(option.value) const newValues = selectedValues.includes(optionValue)
? selectedValues.filter((v) => v !== option.value) ? selectedValues.filter((v) => v !== optionValue)
: [...selectedValues, option.value]; : [...selectedValues, optionValue];
onChange?.(newValues); onChange?.(newValues);
} else { } else {
// Replace selection in single-select mode // Replace selection in single-select mode
onChange?.(option.value); onChange?.(optionValue);
} }
setSearchTerm(""); // Clear search so selected label shows setSearchTerm(""); // Clear search so selected label shows
closeDropdown(); closeDropdown();
@ -240,6 +232,9 @@ export default function Combobox({
<div className={dropdownWrapperClasses}> <div className={dropdownWrapperClasses}>
<SearchResultList <SearchResultList
options={filteredOptions} options={filteredOptions}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
getOptionSubtitle={getOptionSubtitle}
selectedValues={selectedValues} selectedValues={selectedValues}
focusedIndex={focusedIndex} focusedIndex={focusedIndex}
maxHeight={dropdownHeight} maxHeight={dropdownHeight}

View File

@ -1,14 +1,14 @@
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import clsx from "clsx"; import clsx from "clsx";
import Combobox, { import Combobox, { type ComboboxSize } from "../Combobox/Combobox";
type ComboboxOption,
type ComboboxSize,
} from "../Combobox/Combobox";
import ListCard from "../ListCard/ListCard"; import ListCard from "../ListCard/ListCard";
export interface ParticipantPickerProps export interface ParticipantPickerProps<T>
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> { extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
options: ComboboxOption[]; options: T[];
getOptionValue: (option: T) => string;
getOptionLabel: (option: T) => string;
getOptionSubtitle?: (option: T) => string | undefined;
value: string[]; value: string[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
placeholder?: string; placeholder?: string;
@ -28,8 +28,11 @@ const widthClasses: Record<ComboboxSize, string> = {
lg: "w-(--text-input-default-width-lg)", lg: "w-(--text-input-default-width-lg)",
}; };
export default function ParticipantPicker({ export default function ParticipantPicker<T>({
options, options,
getOptionValue,
getOptionLabel,
getOptionSubtitle,
value, value,
onChange, onChange,
placeholder = "Sök...", placeholder = "Sök...",
@ -43,12 +46,14 @@ export default function ParticipantPicker({
className, className,
style, style,
...props ...props
}: ParticipantPickerProps) { }: ParticipantPickerProps<T>) {
const handleRemove = (valueToRemove: string) => { const handleRemove = (valueToRemove: string) => {
onChange(value.filter((v) => v !== valueToRemove)); onChange(value.filter((v) => v !== valueToRemove));
}; };
const selectedOptions = options.filter((opt) => value.includes(opt.value)); const selectedOptions = options.filter((opt) =>
value.includes(getOptionValue(opt)),
);
const containerClasses = clsx( const containerClasses = clsx(
"flex flex-col gap-(--spacing-sm)", "flex flex-col gap-(--spacing-sm)",
@ -62,6 +67,9 @@ export default function ParticipantPicker({
<div className={containerClasses} style={widthStyle} {...props}> <div className={containerClasses} style={widthStyle} {...props}>
<Combobox <Combobox
options={options} options={options}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
getOptionSubtitle={getOptionSubtitle}
value={value} value={value}
onChange={(v) => onChange(v as string[])} onChange={(v) => onChange(v as string[])}
placeholder={placeholder} placeholder={placeholder}
@ -77,10 +85,10 @@ export default function ParticipantPicker({
<div className="flex flex-col gap-(--spacing-sm)"> <div className="flex flex-col gap-(--spacing-sm)">
{selectedOptions.map((option) => ( {selectedOptions.map((option) => (
<ListCard <ListCard
key={option.value} key={getOptionValue(option)}
title={option.label} title={getOptionLabel(option)}
subtitle={option.subtitle} subtitle={getOptionSubtitle?.(option)}
onRemove={() => handleRemove(option.value)} onRemove={() => handleRemove(getOptionValue(option))}
/> />
))} ))}
</div> </div>

View File

@ -2,20 +2,17 @@ import type { HTMLAttributes } from "react";
import clsx from "clsx"; import clsx from "clsx";
import ListItem from "../ListItem/ListItem"; import ListItem from "../ListItem/ListItem";
export interface SearchResultOption { export interface SearchResultListProps<T>
value: string;
label: string;
subtitle?: string;
}
export interface SearchResultListProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> { extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
ansv7779 marked this conversation as resolved
Review

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.

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: SearchResultOption[]; options: T[];
getOptionValue: (option: T) => string;
getOptionLabel: (option: T) => string;
getOptionSubtitle?: (option: T) => string | undefined;
selectedValues?: string[]; selectedValues?: string[];
focusedIndex?: number; focusedIndex?: number;
maxHeight?: number; maxHeight?: number;
noResultsText?: string; noResultsText?: string;
onSelect?: (option: SearchResultOption) => void; onSelect?: (option: T) => void;
itemRefs?: React.RefObject<(HTMLDivElement | null)[]>; itemRefs?: React.RefObject<(HTMLDivElement | null)[]>;
} }
@ -31,8 +28,11 @@ const noResultsClasses =
const dividerClasses = const dividerClasses =
"border-t border-base-ink-soft [border-top-width:var(--border-width-sm)]"; "border-t border-base-ink-soft [border-top-width:var(--border-width-sm)]";
export default function SearchResultList({ export default function SearchResultList<T>({
options, options,
getOptionValue,
getOptionLabel,
getOptionSubtitle,
selectedValues = [], selectedValues = [],
focusedIndex = -1, focusedIndex = -1,
maxHeight = 300, maxHeight = 300,
@ -42,8 +42,9 @@ export default function SearchResultList({
className, className,
style, style,
...props ...props
}: SearchResultListProps) { }: SearchResultListProps<T>) {
const isSelected = (value: string) => selectedValues.includes(value); const isSelected = (option: T) =>
selectedValues.includes(getOptionValue(option));
return ( return (
<div <div
@ -55,7 +56,7 @@ export default function SearchResultList({
{options.length > 0 ? ( {options.length > 0 ? (
options.map((option, index) => ( options.map((option, index) => (
<div <div
key={option.value} key={getOptionValue(option)}
ref={(el) => { ref={(el) => {
if (itemRefs?.current) { if (itemRefs?.current) {
itemRefs.current[index] = el; itemRefs.current[index] = el;
@ -64,9 +65,9 @@ export default function SearchResultList({
className={index > 0 ? dividerClasses : undefined} className={index > 0 ? dividerClasses : undefined}
> >
<ListItem <ListItem
title={option.label} title={getOptionLabel(option)}
subtitle={option.subtitle} subtitle={getOptionSubtitle?.(option)}
selected={isSelected(option.value)} selected={isSelected(option)}
focused={index === focusedIndex} focused={index === focusedIndex}
onClick={() => onSelect?.(option)} onClick={() => onSelect?.(option)}
/> />

View File

@ -8,7 +8,13 @@ import Combobox from "../components/Combobox/Combobox";
import ListCard from "../components/ListCard/ListCard"; import ListCard from "../components/ListCard/ListCard";
import ParticipantPicker from "../components/ParticipantPicker/ParticipantPicker"; import ParticipantPicker from "../components/ParticipantPicker/ParticipantPicker";
const peopleOptions = [ interface Person {
value: string;
label: string;
subtitle: string;
}
const peopleOptions: Person[] = [
{ value: "1", label: "Lennart Johansson", subtitle: "lejo1891" }, { value: "1", label: "Lennart Johansson", subtitle: "lejo1891" },
{ value: "2", label: "Mats Rubarth", subtitle: "matsrub1891" }, { value: "2", label: "Mats Rubarth", subtitle: "matsrub1891" },
{ value: "3", label: "Daniel Tjernström", subtitle: "datj1891" }, { value: "3", label: "Daniel Tjernström", subtitle: "datj1891" },
@ -17,6 +23,10 @@ const peopleOptions = [
{ value: "6", label: "Kurre Hamrin", subtitle: "kuha1891" }, { 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");
@ -206,6 +216,9 @@ export default function ComponentLibrary() {
<div className="max-w-96"> <div className="max-w-96">
<SearchResultList <SearchResultList
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
selectedValues={["3"]} selectedValues={["3"]}
focusedIndex={1} focusedIndex={1}
noResultsText="Inga resultat" noResultsText="Inga resultat"
@ -216,7 +229,12 @@ export default function ComponentLibrary() {
<section className="mt-lg"> <section className="mt-lg">
<h2 className="mb-md">SearchResultList - Empty</h2> <h2 className="mb-md">SearchResultList - Empty</h2>
<div className="max-w-96"> <div className="max-w-96">
<SearchResultList options={[]} noResultsText="Inga resultat" /> <SearchResultList
options={[]}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
noResultsText="Inga resultat"
/>
</div> </div>
</section> </section>
@ -225,6 +243,9 @@ export default function ComponentLibrary() {
<div className="flex flex-col gap-md"> <div className="flex flex-col gap-md">
<Combobox <Combobox
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
value={selectedPerson} value={selectedPerson}
onChange={(v) => setSelectedPerson(v as string)} onChange={(v) => setSelectedPerson(v as string)}
placeholder="Sök..." placeholder="Sök..."
@ -244,6 +265,9 @@ export default function ComponentLibrary() {
<div className="flex flex-col gap-md"> <div className="flex flex-col gap-md">
<Combobox <Combobox
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
value={selectedPeople} value={selectedPeople}
onChange={(v) => setSelectedPeople(v as string[])} onChange={(v) => setSelectedPeople(v as string[])}
placeholder="Sök..." placeholder="Sök..."
@ -266,6 +290,9 @@ export default function ComponentLibrary() {
<div className="flex flex-wrap items-start gap-md"> <div className="flex flex-wrap items-start gap-md">
<Combobox <Combobox
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
placeholder="Small" placeholder="Small"
size="sm" size="sm"
label="Small" label="Small"
@ -273,6 +300,9 @@ export default function ComponentLibrary() {
/> />
<Combobox <Combobox
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
placeholder="Medium" placeholder="Medium"
size="md" size="md"
label="Medium" label="Medium"
@ -280,6 +310,9 @@ export default function ComponentLibrary() {
/> />
<Combobox <Combobox
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
placeholder="Large" placeholder="Large"
size="lg" size="lg"
label="Large" label="Large"
@ -293,6 +326,9 @@ export default function ComponentLibrary() {
<div className="flex flex-col gap-md"> <div className="flex flex-col gap-md">
<Combobox <Combobox
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
placeholder="Sök..." placeholder="Sök..."
customWidth="350px" customWidth="350px"
label="Custom width" label="Custom width"
@ -300,6 +336,9 @@ export default function ComponentLibrary() {
/> />
<Combobox <Combobox
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
placeholder="Sök..." placeholder="Sök..."
label="Full width" label="Full width"
fullWidth fullWidth
@ -320,6 +359,9 @@ export default function ComponentLibrary() {
<h2 className="mb-md">ParticipantPicker</h2> <h2 className="mb-md">ParticipantPicker</h2>
<ParticipantPicker <ParticipantPicker
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
value={participants} value={participants}
onChange={setParticipants} onChange={setParticipants}
placeholder="Sök deltagare..." placeholder="Sök deltagare..."
@ -332,6 +374,9 @@ export default function ComponentLibrary() {
<div className="flex flex-wrap items-start gap-md"> <div className="flex flex-wrap items-start gap-md">
<ParticipantPicker <ParticipantPicker
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
value={participants} value={participants}
onChange={setParticipants} onChange={setParticipants}
placeholder="Small" placeholder="Small"
@ -341,6 +386,9 @@ export default function ComponentLibrary() {
/> />
<ParticipantPicker <ParticipantPicker
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
value={participants} value={participants}
onChange={setParticipants} onChange={setParticipants}
placeholder="Medium" placeholder="Medium"
@ -350,6 +398,9 @@ export default function ComponentLibrary() {
/> />
<ParticipantPicker <ParticipantPicker
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
value={participants} value={participants}
onChange={setParticipants} onChange={setParticipants}
placeholder="Large" placeholder="Large"
@ -365,6 +416,9 @@ export default function ComponentLibrary() {
<div className="flex flex-col gap-md"> <div className="flex flex-col gap-md">
<ParticipantPicker <ParticipantPicker
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
value={participants} value={participants}
onChange={setParticipants} onChange={setParticipants}
placeholder="Sök..." placeholder="Sök..."
@ -374,6 +428,9 @@ export default function ComponentLibrary() {
/> />
<ParticipantPicker <ParticipantPicker
options={peopleOptions} options={peopleOptions}
getOptionValue={getPersonValue}
getOptionLabel={getPersonLabel}
getOptionSubtitle={getPersonSubtitle}
value={participants} value={participants}
onChange={setParticipants} onChange={setParticipants}
placeholder="Sök..." placeholder="Sök..."