Files
InvokeAI/invokeai/frontend/web/src/common/components/Picker/Picker.tsx
psychedelicious 7948bca864 build(ui): adopt sonda over rollup-plugin-visualizer to examine bundle
Requires a change to tsconfig module/moduleResolution settings. We were
on old legacy values anyways so good to update it.
2025-06-26 20:00:39 +10:00

1085 lines
32 KiB
TypeScript

import type { BoxProps, SystemStyleObject } from '@invoke-ai/ui-library';
import {
Badge,
Divider,
Flex,
IconButton,
Input,
InputGroup,
InputRightElement,
Spacer,
Text,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { typedMemo } from 'common/util/typedMemo';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ReadableAtom, WritableAtom } from 'nanostores';
import { atom, computed } from 'nanostores';
import type { ChangeEvent, MouseEventHandler, PropsWithChildren, RefObject } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import {
PiArrowCounterClockwiseBold,
PiArrowsInLineVerticalBold,
PiArrowsOutLineVerticalBold,
PiXBold,
} from 'react-icons/pi';
import { assert } from 'tsafe';
import { useDebounce } from 'use-debounce';
const NO_WHEEL_NO_DRAG_CLASS = `${NO_WHEEL_CLASS} ${NO_DRAG_CLASS}`;
const uniqueGroupKey = Symbol('uniqueGroupKey');
export type Group<T extends object> = {
/**
* The unique id of the group.
*/
id: string;
/**
* The options in the group.
*/
options: T[];
/**
* The color of the group. Used to style the group toggle button and vertical group line.
*
* It can be a CSS color string or theme color token.
*/
color?: string;
/**
* The name of the group.
*/
name?: string;
/**
* The short name of the group. Used to display for the group toggle button.
*/
shortName?: string;
/**
* The description of the group. Used to display in the group toggle button.
*/
description?: string;
/**
* A function that returns a "count" string for the group. It will be called with the number of matching options in
* the group.
*/
getOptionCountString?: (count: number) => string;
/**
* A unique key used for type-checking the group. Use the `buildGroup` function to create a group, which will set this key.
*/
[uniqueGroupKey]: true;
};
type OptionOrGroup<T extends object> = T | Group<T>;
export const buildGroup = <T extends object>(group: Omit<Group<T>, typeof uniqueGroupKey>): Group<T> => ({
...group,
[uniqueGroupKey]: true,
});
const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is Group<T> => {
return uniqueGroupKey in optionOrGroup && optionOrGroup[uniqueGroupKey] === true;
};
const DefaultOptionComponent = typedMemo(<T extends object>({ option }: { option: T }) => {
const { getOptionId } = usePickerContext();
return <Text fontWeight="bold">{getOptionId(option)}</Text>;
});
DefaultOptionComponent.displayName = 'DefaultOptionComponent';
const DefaultGroupComponent = typedMemo(
<T extends object>({ group, children }: PropsWithChildren<{ group: Group<T> }>) => {
return (
<Flex flexDir="column" gap={2} w="full">
<Text fontWeight="bold">{group.id}</Text>
<Flex flexDir="column" gap={1} w="full">
{children}
</Flex>
</Flex>
);
}
);
DefaultGroupComponent.displayName = 'DefaultGroupComponent';
const NoOptionsFallbackWrapper = typedMemo(({ children }: PropsWithChildren) => {
const { t } = useTranslation();
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
{typeof children === 'string' ? (
<Text variant="subtext">{children}</Text>
) : (
(children ?? <Text variant="subtext">{t('common.noOptions')}</Text>)
)}
</Flex>
);
});
NoOptionsFallbackWrapper.displayName = 'NoOptionsFallbackWrapper';
const NoMatchesFallbackWrapper = typedMemo(({ children }: PropsWithChildren) => {
const { t } = useTranslation();
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
{typeof children === 'string' ? (
<Text variant="subtext">{children}</Text>
) : (
(children ?? <Text variant="subtext">{t('common.noMatches')}</Text>)
)}
</Flex>
);
});
NoMatchesFallbackWrapper.displayName = 'NoMatchesFallbackWrapper';
type PickerProps<T extends object> = {
/**
* The options to display in the picker. This can be a flat array of options or an array of groups.
*/
optionsOrGroups: OptionOrGroup<T>[];
/**
* A function that returns the id of an option.
*/
getOptionId: (option: T) => string;
/**
* A function that returns true if the option matches the search term.
*/
isMatch: (option: T, searchTerm: string) => boolean;
/**
* A function that returns true if the option is disabled.
*/
getIsOptionDisabled?: (option: T) => boolean;
/**
* The currently selected item.
*/
selectedOption?: T;
/**
* A function that is called when an option is selected.
*/
onSelect?: (option: T) => void;
/**
* A function that is called when the picker is closed.
*/
onClose?: () => void;
/**
* A placeholder for the search input.
*/
searchPlaceholder?: string;
/**
* A ref to an imperative handle that can be used to control the picker.
*/
handleRef?: React.Ref<PickerContextState<T>>;
/**
* A custom option component. If not provided, a default option component will be used.
*/
OptionComponent?: React.ComponentType<{ option: T } & BoxProps>;
/**
* A component to render next to the search bar.
*/
NextToSearchBar?: React.ReactNode;
/**
* A fallback component to display when there are no options. If a string is provided, it will be formatted
* as a text element with appropriate styling. If a React node is provided, it will be rendered as is.
*/
noOptionsFallback?: React.ReactNode;
/**
* A fallback component to display when there are no matches. If a string is provided, it will be formatted
* as a text element with appropriate styling. If a React node is provided, it will be rendered as is.
*/
noMatchesFallback?: React.ReactNode;
/**
* Whether the picker should be searchable. If true, renders a search input.
*/
searchable?: boolean;
};
export type PickerContextState<T extends object> = {
$optionsOrGroups: WritableAtom<OptionOrGroup<T>[]>;
$groupStatusMap: WritableAtom<GroupStatusMap>;
$compactView: WritableAtom<boolean>;
$activeOptionId: WritableAtom<string | undefined>;
$filteredOptions: WritableAtom<OptionOrGroup<T>[]>;
$flattenedFilteredOptions: ReadableAtom<T[]>;
$totalOptionCount: ReadableAtom<number>;
$hasOptions: ReadableAtom<boolean>;
$filteredOptionsCount: ReadableAtom<number>;
$hasFilteredOptions: ReadableAtom<boolean>;
$areAllGroupsDisabled: ReadableAtom<boolean>;
$selectedItem: WritableAtom<T | undefined>;
$selectedItemId: ReadableAtom<string | undefined>;
$searchTerm: WritableAtom<string>;
searchPlaceholder?: string;
toggleGroup: (id: string) => void;
getOptionId: (option: T) => string;
isMatch: (option: T, searchTerm: string) => boolean;
getIsOptionDisabled?: (option: T) => boolean;
onSelectById: (id: string) => void;
onClose?: () => void;
rootRef: RefObject<HTMLDivElement>;
inputRef: RefObject<HTMLInputElement>;
noOptionsFallback?: React.ReactNode;
noMatchesFallback?: React.ReactNode;
OptionComponent: React.ComponentType<{ option: T } & BoxProps>;
NextToSearchBar?: React.ReactNode;
searchable?: boolean;
};
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const PickerContext = createContext<PickerContextState<any> | null>(null);
export const usePickerContext = <T extends object>(): PickerContextState<T> => {
const context = useContext(PickerContext);
assert(context !== null, 'usePickerContext must be used within a PickerProvider');
return context;
};
export const getRegex = (searchTerm: string) => {
const terms = searchTerm
.trim()
.replace(/[-[\]{}()*+!<=:?./\\^$|#,]/g, '')
.split(' ')
.filter((term) => term.length > 0);
if (terms.length === 0) {
return new RegExp('', 'gi');
}
// Create positive lookaheads for each term - matches in any order
const pattern = terms.map((term) => `(?=.*${term})`).join('');
return new RegExp(`${pattern}.+`, 'i');
};
const getFirstOption = <T extends object>(options: OptionOrGroup<T>[]): T | undefined => {
const firstOptionOrGroup = options[0];
if (!firstOptionOrGroup) {
return;
}
if (isGroup(firstOptionOrGroup)) {
return firstOptionOrGroup.options[0];
} else {
return firstOptionOrGroup;
}
};
const getFirstOptionId = <T extends object>(
options: OptionOrGroup<T>[],
getOptionId: (item: T) => string
): string | undefined => {
const firstOptionOrGroup = getFirstOption(options);
if (firstOptionOrGroup) {
return getOptionId(firstOptionOrGroup);
} else {
return undefined;
}
};
const findOption = <T extends object>(
options: OptionOrGroup<T>[],
id: string,
getOptionId: (item: T) => string
): T | undefined => {
for (const optionOrGroup of options) {
if (isGroup(optionOrGroup)) {
const option = optionOrGroup.options.find((opt) => getOptionId(opt) === id);
if (option) {
return option;
}
} else {
if (getOptionId(optionOrGroup) === id) {
return optionOrGroup;
}
}
}
};
const flattenOptions = <T extends object>(options: OptionOrGroup<T>[]): T[] => {
const flattened: T[] = [];
for (const optionOrGroup of options) {
if (isGroup(optionOrGroup)) {
flattened.push(...optionOrGroup.options);
} else {
flattened.push(optionOrGroup);
}
}
return flattened;
};
type GroupStatusMap = Record<string, boolean>;
const useTogglableGroups = <T extends object>(options: OptionOrGroup<T>[]) => {
const groupsWithOptions = useMemo(() => {
const ids: string[] = [];
for (const optionOrGroup of options) {
if (isGroup(optionOrGroup) && !ids.includes(optionOrGroup.id)) {
ids.push(optionOrGroup.id);
}
}
return ids;
}, [options]);
const [$groupStatusMap] = useState(atom<GroupStatusMap>({}));
const [$areAllGroupsDisabled] = useState(() =>
computed($groupStatusMap, (groupStatusMap) => Object.values(groupStatusMap).every((status) => status === false))
);
useEffect(() => {
const groupStatusMap = $groupStatusMap.get();
const newMap: GroupStatusMap = {};
for (const id of groupsWithOptions) {
if (newMap[id] === undefined) {
newMap[id] = false;
} else if (groupStatusMap[id] !== undefined) {
newMap[id] = groupStatusMap[id];
}
}
$groupStatusMap.set(newMap);
}, [groupsWithOptions, $groupStatusMap]);
const toggleGroup = useCallback(
(idToToggle: string) => {
const groupStatusMap = $groupStatusMap.get();
const newMap: GroupStatusMap = {};
for (const id of groupsWithOptions) {
const prevStatus = Boolean(groupStatusMap[id]);
newMap[id] = id === idToToggle ? !prevStatus : prevStatus;
}
$groupStatusMap.set(newMap);
},
[$groupStatusMap, groupsWithOptions]
);
return { $groupStatusMap, $areAllGroupsDisabled, toggleGroup } as const;
};
const useKeyboardNavigation = <T extends object>() => {
const { getOptionId, $activeOptionId, $flattenedFilteredOptions, onSelectById, rootRef, onClose, inputRef } =
usePickerContext<T>();
const setValueAndScrollIntoView = useCallback(
(id: string) => {
$activeOptionId.set(id);
const rootEl = rootRef.current;
if (!rootEl) {
return;
}
const itemEl = rootEl.querySelector(`#${CSS.escape(id)}`);
if (!itemEl) {
return;
}
itemEl.scrollIntoView({ block: 'nearest' });
},
[$activeOptionId, rootRef]
);
const prev = useCallback(
(e: React.KeyboardEvent) => {
e.preventDefault();
const flattenedFilteredOptions = $flattenedFilteredOptions.get();
const activeOptionId = $activeOptionId.get();
if (flattenedFilteredOptions.length === 0) {
return;
}
if (e.metaKey) {
const item = flattenedFilteredOptions.at(0);
if (item) {
setValueAndScrollIntoView(getOptionId(item));
}
return;
}
const currentIndex = flattenedFilteredOptions.findIndex((item) => getOptionId(item) === activeOptionId);
if (currentIndex < 0) {
return;
}
let newIndex = currentIndex - 1;
if (newIndex < 0) {
newIndex = flattenedFilteredOptions.length - 1;
}
const item = flattenedFilteredOptions.at(newIndex);
if (item) {
setValueAndScrollIntoView(getOptionId(item));
}
},
[$activeOptionId, $flattenedFilteredOptions, setValueAndScrollIntoView, getOptionId]
);
const next = useCallback(
(e: React.KeyboardEvent) => {
e.preventDefault();
const activeOptionId = $activeOptionId.get();
const flattenedFilteredOptions = $flattenedFilteredOptions.get();
if (flattenedFilteredOptions.length === 0) {
return;
}
if (e.metaKey) {
const item = flattenedFilteredOptions.at(-1);
if (item) {
setValueAndScrollIntoView(getOptionId(item));
}
return;
}
const currentIndex = flattenedFilteredOptions.findIndex((item) => getOptionId(item) === activeOptionId);
if (currentIndex < 0) {
return;
}
let newIndex = currentIndex + 1;
if (newIndex >= flattenedFilteredOptions.length) {
newIndex = 0;
}
const item = flattenedFilteredOptions.at(newIndex);
if (item) {
setValueAndScrollIntoView(getOptionId(item));
}
},
[$activeOptionId, $flattenedFilteredOptions, setValueAndScrollIntoView, getOptionId]
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowUp') {
prev(e);
} else if (e.key === 'ArrowDown') {
next(e);
} else if (e.key === 'Enter') {
const activeOptionId = $activeOptionId.get();
if (!activeOptionId) {
return;
}
onSelectById(activeOptionId);
} else if (e.key === 'Escape') {
onClose?.();
} else if (e.key === '/') {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
},
[prev, next, $activeOptionId, onSelectById, onClose, inputRef]
);
const keyboardNavProps = useMemo(() => {
return {
onKeyDown,
};
}, [onKeyDown]);
return keyboardNavProps;
};
const countOptions = <T extends object>(optionsOrGroups: OptionOrGroup<T>[]) => {
let count = 0;
for (const optionOrGroup of optionsOrGroups) {
if (isGroup(optionOrGroup)) {
count += optionOrGroup.options.length;
} else {
count++;
}
}
return count;
};
export const Picker = typedMemo(<T extends object>(props: PickerProps<T>) => {
const {
getOptionId,
optionsOrGroups,
handleRef,
isMatch,
getIsOptionDisabled,
onClose,
onSelect,
selectedOption,
searchPlaceholder,
noMatchesFallback,
noOptionsFallback,
OptionComponent = DefaultOptionComponent,
NextToSearchBar,
searchable,
} = props;
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { $groupStatusMap, $areAllGroupsDisabled, toggleGroup } = useTogglableGroups(optionsOrGroups);
const $activeOptionId = useState(() => atom(getFirstOptionId(optionsOrGroups, getOptionId)))[0];
const $compactView = useState(() => atom(true))[0];
const $optionsOrGroups = useState(() => atom(optionsOrGroups))[0];
const $totalOptionCount = useState(() => computed([$optionsOrGroups], countOptions))[0];
const $filteredOptions = useState(() => atom<OptionOrGroup<T>[]>([]))[0];
const $flattenedFilteredOptions = useState(() => computed([$filteredOptions], flattenOptions))[0];
const $hasOptions = useState(() => computed([$totalOptionCount], (count) => count > 0))[0];
const $filteredOptionsCount = useState(() => computed([$flattenedFilteredOptions], (options) => options.length))[0];
const $hasFilteredOptions = useState(() => computed([$filteredOptionsCount], (count) => count > 0))[0];
const $selectedItem = useState(() => atom<T | undefined>(undefined))[0];
const $searchTerm = useState(() => atom(''))[0];
const $selectedItemId = useState(() =>
computed([$selectedItem], (item) => (item ? getOptionId(item) : undefined))
)[0];
const onSelectById = useCallback(
(id: string) => {
const options = $filteredOptions.get();
const item = findOption(options, id, getOptionId);
if (!item) {
// Model not found? We should never get here.
return;
}
onSelect?.(item);
},
[$filteredOptions, getOptionId, onSelect]
);
// Sync the picker's nanostores when props change
useEffect(() => {
$selectedItem.set(selectedOption);
}, [$selectedItem, selectedOption]);
useEffect(() => {
$optionsOrGroups.set(optionsOrGroups);
}, [optionsOrGroups, $optionsOrGroups]);
const ctx = useMemo(
() =>
({
$optionsOrGroups,
$groupStatusMap,
$compactView,
$activeOptionId,
$filteredOptions,
$flattenedFilteredOptions,
$totalOptionCount,
$selectedItem,
$searchTerm,
getOptionId,
isMatch,
getIsOptionDisabled,
onSelectById,
noOptionsFallback,
noMatchesFallback,
toggleGroup,
rootRef,
inputRef,
searchPlaceholder,
OptionComponent,
NextToSearchBar,
onClose,
searchable,
$areAllGroupsDisabled,
$selectedItemId,
$hasOptions,
$hasFilteredOptions,
$filteredOptionsCount,
}) satisfies PickerContextState<T>,
[
$optionsOrGroups,
$groupStatusMap,
$compactView,
$activeOptionId,
$filteredOptions,
$flattenedFilteredOptions,
$totalOptionCount,
$selectedItem,
$searchTerm,
getOptionId,
isMatch,
getIsOptionDisabled,
onSelectById,
noOptionsFallback,
noMatchesFallback,
toggleGroup,
searchPlaceholder,
OptionComponent,
NextToSearchBar,
onClose,
searchable,
$areAllGroupsDisabled,
$selectedItemId,
$hasOptions,
$hasFilteredOptions,
$filteredOptionsCount,
]
);
useImperativeHandle(handleRef, () => ctx, [ctx]);
return (
<PickerContext.Provider value={ctx}>
<PickerContainer>
<PickerSearchBar />
<Flex tabIndex={-1} w="full" flexGrow={1}>
<NoOptionsFallback />
<NoMatchesFallback />
<PickerList />
</Flex>
</PickerContainer>
<PickerSyncer />
</PickerContext.Provider>
);
});
Picker.displayName = 'Picker';
const PickerSyncer = typedMemo(<T extends object>() => {
const {
$optionsOrGroups,
$searchTerm,
$activeOptionId,
$groupStatusMap,
$areAllGroupsDisabled,
$filteredOptions,
searchable,
isMatch,
getOptionId,
} = usePickerContext<T>();
const searchTerm = useStore($searchTerm);
const groupStatusMap = useStore($groupStatusMap);
const areAllGroupsDisabled = useStore($areAllGroupsDisabled);
const optionsOrGroups = useStore($optionsOrGroups);
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
useEffect(() => {
if (!debouncedSearchTerm || !searchable) {
const filtered = optionsOrGroups.filter((item) => {
if (isGroup(item)) {
return groupStatusMap[item.id] || areAllGroupsDisabled;
} else {
return true;
}
});
$filteredOptions.set(filtered);
$activeOptionId.set(getFirstOptionId(filtered, getOptionId));
} else {
const lowercasedSearchTerm = debouncedSearchTerm.toLowerCase();
const filtered = [];
for (const item of optionsOrGroups) {
if (isGroup(item)) {
if (!groupStatusMap[item.id] && !areAllGroupsDisabled) {
continue;
}
const filteredItems = item.options.filter((item) => isMatch(item, lowercasedSearchTerm));
if (filteredItems.length > 0) {
filtered.push({ ...item, options: filteredItems });
}
} else {
if (isMatch(item, debouncedSearchTerm)) {
filtered.push(item);
}
}
}
$filteredOptions.set(filtered);
$activeOptionId.set(getFirstOptionId(filtered, getOptionId));
}
}, [
debouncedSearchTerm,
$activeOptionId,
getOptionId,
isMatch,
$filteredOptions,
searchable,
optionsOrGroups,
groupStatusMap,
areAllGroupsDisabled,
]);
return null;
});
PickerSyncer.displayName = 'PickerSyncer';
const PickerContainer = typedMemo(({ children }: PropsWithChildren) => {
const { rootRef } = usePickerContext();
const keyboardNavProps = useKeyboardNavigation();
return (
<Flex
className={NO_WHEEL_NO_DRAG_CLASS}
tabIndex={-1}
ref={rootRef}
flexGrow={1}
flexDir="column"
p={2}
w="full"
h="full"
gap={2}
{...keyboardNavProps}
>
{children}
</Flex>
);
});
PickerContainer.displayName = 'PickerContainer';
const NoOptionsFallback = typedMemo(<T extends object>() => {
const { noOptionsFallback, $hasOptions } = usePickerContext<T>();
const hasOptions = useStore($hasOptions);
if (hasOptions) {
return null;
}
return <NoOptionsFallbackWrapper>{noOptionsFallback}</NoOptionsFallbackWrapper>;
});
NoOptionsFallback.displayName = 'NoOptionsFallback';
const NoMatchesFallback = typedMemo(<T extends object>() => {
const { noMatchesFallback, $hasOptions, $hasFilteredOptions } = usePickerContext<T>();
const hasOptions = useStore($hasOptions);
const hasFilteredOptions = useStore($hasFilteredOptions);
if (!hasOptions) {
return null;
}
if (hasFilteredOptions) {
return null;
}
return <NoMatchesFallbackWrapper>{noMatchesFallback}</NoMatchesFallbackWrapper>;
});
NoMatchesFallback.displayName = 'NoMatchesFallback';
const PickerSearchBar = typedMemo(<T extends object>() => {
const { NextToSearchBar, searchable } = usePickerContext<T>();
if (!searchable) {
return null;
}
return (
<Flex flexDir="column" w="full" gap={2}>
<Flex gap={2} alignItems="center">
<SearchInput />
{NextToSearchBar}
<CompactViewToggleButton />
</Flex>
<GroupToggleButtons />
</Flex>
);
});
PickerSearchBar.displayName = 'PickerSearchBar';
const SearchInput = typedMemo(<T extends object>() => {
const { inputRef, $totalOptionCount, $searchTerm, searchPlaceholder } = usePickerContext<T>();
const { t } = useTranslation();
const searchTerm = useStore($searchTerm);
const totalOptionCount = useStore($totalOptionCount);
const placeholder = searchPlaceholder ?? t('common.search');
const resetSearchTerm = useCallback(() => {
$searchTerm.set('');
inputRef.current?.focus();
}, [$searchTerm, inputRef]);
const onChangeSearchTerm = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
$searchTerm.set(e.target.value);
},
[$searchTerm]
);
return (
<InputGroup>
<Input ref={inputRef} value={searchTerm} onChange={onChangeSearchTerm} placeholder={placeholder} />
{searchTerm && (
<InputRightElement h="full" pe={2}>
<IconButton
onClick={resetSearchTerm}
size="sm"
variant="link"
aria-label={t('common.clear')}
tooltip={t('common.clear')}
icon={<PiXBold />}
isDisabled={totalOptionCount === 0}
disabled={false}
/>
</InputRightElement>
)}
</InputGroup>
);
});
SearchInput.displayName = 'SearchInput';
const GroupToggleButtons = typedMemo(<T extends object>() => {
const { $optionsOrGroups, $groupStatusMap, $areAllGroupsDisabled } = usePickerContext<T>();
const { t } = useTranslation();
const $groups = useState(() =>
computed([$optionsOrGroups], (optionsOrGroups) => {
const _groups: Group<T>[] = [];
for (const optionOrGroup of optionsOrGroups) {
if (isGroup(optionOrGroup)) {
_groups.push(optionOrGroup);
}
}
return _groups;
})
)[0];
const groups = useStore($groups);
const areAllGroupsDisabled = useStore($areAllGroupsDisabled);
const onClick = useCallback<MouseEventHandler>(() => {
const newMap: GroupStatusMap = {};
for (const { id } of groups) {
newMap[id] = false;
}
$groupStatusMap.set(newMap);
}, [$groupStatusMap, groups]);
if (!groups.length) {
return null;
}
return (
<Flex gap={2} alignItems="center">
{groups.map((group) => (
<GroupToggleButton key={group.id} group={group} />
))}
<Spacer />
<IconButton
icon={<PiArrowCounterClockwiseBold />}
aria-label={t('common.reset')}
tooltip={t('common.reset')}
size="sm"
variant="link"
alignSelf="stretch"
onClick={onClick}
// When a focused element is disabled, it blurs. This closes the popover. Fake the disabled state to prevent this.
// See: https://github.com/chakra-ui/chakra-ui/issues/7965
opacity={areAllGroupsDisabled ? 0.5 : undefined}
pointerEvents={areAllGroupsDisabled ? 'none' : undefined}
/>
</Flex>
);
});
GroupToggleButtons.displayName = 'GroupToggleButtons';
const CompactViewToggleButton = typedMemo(<T extends object>() => {
const { t } = useTranslation();
const { $compactView } = usePickerContext<T>();
const compactView = useStore($compactView);
const onClick = useCallback(() => {
$compactView.set(!$compactView.get());
}, [$compactView]);
const label = compactView ? t('common.fullView') : t('common.compactView');
const icon = compactView ? <PiArrowsOutLineVerticalBold /> : <PiArrowsInLineVerticalBold />;
return <IconButton aria-label={label} tooltip={label} size="sm" variant="ghost" icon={icon} onClick={onClick} />;
});
CompactViewToggleButton.displayName = 'CompactViewToggleButton';
const GroupToggleButton = typedMemo(<T extends object>({ group }: { group: Group<T> }) => {
const { toggleGroup, $groupStatusMap } = usePickerContext<T>();
const groupStatusMap = useStore($groupStatusMap);
const onClick = useCallback(() => {
toggleGroup(group.id);
}, [group.id, toggleGroup]);
const groupColor = getGroupColor(group);
const shortName = getGroupShortName(group);
const bg = groupStatusMap[group.id] ? groupColor : 'transparent';
const color = groupStatusMap[group.id] ? undefined : 'base.200';
return (
<Badge
role="button"
size="xs"
variant="solid"
userSelect="none"
bg={bg}
color={color}
borderColor={groupColor}
borderWidth={1}
onClick={onClick}
>
{shortName}
</Badge>
);
});
GroupToggleButton.displayName = 'GroupToggleButton';
const listSx = {
flexDir: 'column',
w: 'full',
gap: 2,
'&[data-is-compact="true"]': {
gap: 1,
},
} satisfies SystemStyleObject;
const PickerList = typedMemo(<T extends object>() => {
const { getOptionId, $compactView, $filteredOptions } = usePickerContext<T>();
const compactView = useStore($compactView);
const filteredOptions = useStore($filteredOptions);
if (filteredOptions.length === 0) {
return null;
}
return (
<ScrollableContent>
<Flex sx={listSx} data-is-compact={compactView}>
{filteredOptions.map((optionOrGroup, i) => {
if (isGroup(optionOrGroup)) {
const withDivider = !compactView && i < filteredOptions.length - 1;
return (
<React.Fragment key={optionOrGroup.id}>
<PickerGroup group={optionOrGroup} />
{withDivider && <Divider />}
</React.Fragment>
);
} else {
const id = getOptionId(optionOrGroup);
return <PickerOption id={id} key={id} option={optionOrGroup} />;
}
})}
</Flex>
</ScrollableContent>
);
});
PickerList.displayName = 'PickerList';
const PickerGroup = typedMemo(<T extends object>({ group }: { group: Group<T> }) => {
const { getOptionId, $groupStatusMap, $areAllGroupsDisabled } = usePickerContext<T>();
const [$isGroupDisabled] = useState(() =>
computed(
[$groupStatusMap, $areAllGroupsDisabled],
(groupStatusMap, areAllGroupsDisabled) => !groupStatusMap[group.id] && !areAllGroupsDisabled
)
);
const isGroupDisabled = useStore($isGroupDisabled);
if (isGroupDisabled) {
return null;
}
return (
<PickerGroupContainer group={group}>
{group.options.map((item) => {
const id = getOptionId(item);
return <PickerOption key={id} id={id} option={item} />;
})}
</PickerGroupContainer>
);
});
PickerGroup.displayName = 'PickerGroup';
const PickerOption = typedMemo(<T extends object>(props: { id: string; option: T }) => {
const { OptionComponent, $activeOptionId, $selectedItemId, onSelectById, getIsOptionDisabled } =
usePickerContext<T>();
const { id, option } = props;
const [$isActive] = useState(() => computed($activeOptionId, (activeOptionId) => activeOptionId === id));
const [$isSelected] = useState(() => computed($selectedItemId, (selectedItemId) => selectedItemId === id));
const isActive = useStore($isActive);
const isSelected = useStore($isSelected);
const setAsActive = useCallback(() => {
$activeOptionId.set(id);
}, [$activeOptionId, id]);
const select = useCallback(() => {
onSelectById(id);
}, [id, onSelectById]);
const isDisabled = getIsOptionDisabled?.(option) ?? false;
const onPointerMove = isDisabled ? undefined : setAsActive;
const onClick = isDisabled ? undefined : select;
return (
<OptionComponent
tabIndex={-1}
option={option}
id={id}
data-disabled={isDisabled}
data-selected={isSelected}
data-active={isActive}
onPointerMove={onPointerMove}
onClick={onClick}
/>
);
});
PickerOption.displayName = 'PickerOption';
const getGroupColor = <T extends object>(group: Group<T>) => {
return group.color ?? 'base.300';
};
const getGroupShortName = <T extends object>(group: Group<T>) => {
return group.shortName ?? group.name ?? group.id;
};
const getGroupName = <T extends object>(group: Group<T>) => {
return group.name ?? group.id;
};
const getGroupCount = <T extends object>(group: Group<T>, t: ReturnType<typeof useTranslation>['t']) => {
return (
group.getOptionCountString?.(group.options.length) ?? t('common.options_withCount', { count: group.options.length })
);
};
const groupContainerSx = {
flexDir: 'column',
w: 'full',
borderLeftWidth: 4,
ps: 2,
'&[data-all-disabled="true"]': {
opacity: 0.5,
cursor: 'not-allowed',
},
} satisfies SystemStyleObject;
const PickerGroupContainer = typedMemo(
<T extends object>({ group, children }: PropsWithChildren<{ group: Group<T> }>) => {
const { getIsOptionDisabled } = usePickerContext<T>();
const color = getGroupColor(group);
const areAllDisabled = group.options.every((item) => getIsOptionDisabled?.(item) ?? false);
return (
<Flex sx={groupContainerSx} borderLeftColor={color} data-all-disabled={areAllDisabled}>
<PickerGroupHeader group={group} />
<Flex flexDir="column" gap={1} w="full">
{children}
</Flex>
</Flex>
);
}
);
PickerGroupContainer.displayName = 'PickerGroupContainer';
const groupHeaderSx = {
flexDir: 'column',
flex: 1,
ps: 2,
pe: 4,
py: 1,
userSelect: 'none',
position: 'sticky',
top: 0,
bg: 'base.800',
minH: 8,
'&[data-is-compact="true"]': {
ps: 1,
},
} satisfies SystemStyleObject;
const PickerGroupHeader = typedMemo(<T extends object>({ group }: { group: Group<T> }) => {
const { t } = useTranslation();
const { $compactView } = usePickerContext<T>();
const compactView = useStore($compactView);
const color = getGroupColor(group);
const name = getGroupName(group);
const count = getGroupCount(group, t);
return (
<Flex sx={groupHeaderSx} data-is-compact={compactView}>
<Flex gap={2} alignItems="center">
<Text fontSize="sm" fontWeight="semibold" color={color} noOfLines={1}>
{name}
</Text>
<Spacer />
<Text fontSize="sm" color="base.300" noOfLines={1}>
{count}
</Text>
</Flex>
</Flex>
);
});
PickerGroupHeader.displayName = 'PickerGroupHeader';