[Glitch] Allow grouping items in Combobox component

Port 570f2ef4828044312a88ad410fa8e66939e75c56 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-04-17 15:18:04 +02:00 committed by Claire
parent 3916132f2f
commit fe712fa5af
4 changed files with 239 additions and 139 deletions

View File

@ -38,6 +38,18 @@
overscroll-behavior-y: contain;
}
.groupLabel {
padding-block: 12px 4px;
padding-inline: 12px;
font-size: 13px;
font-weight: bold;
text-transform: uppercase;
ul:first-child > & {
padding-top: 4px;
}
}
.menuItem {
display: flex;
align-items: center;

View File

@ -4,40 +4,46 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { ComboboxField } from './combobox_field';
const ComboboxDemo: React.FC = () => {
interface Fruit {
id: string;
name: string;
type: 'citrus' | 'berryish' | 'seedy' | 'stony' | 'longish' | 'chonky';
disabled?: boolean;
}
const ComboboxDemo: React.FC<{ withGroups?: boolean }> = ({ withGroups }) => {
const [searchValue, setSearchValue] = useState('');
const items = [
{ id: '1', name: 'Apple' },
{ id: '2', name: 'Banana' },
{ id: '3', name: 'Cherry', disabled: true },
{ id: '4', name: 'Date' },
{ id: '5', name: 'Fig', disabled: true },
{ id: '6', name: 'Grape' },
{ id: '7', name: 'Honeydew' },
{ id: '8', name: 'Kiwi' },
{ id: '9', name: 'Lemon' },
{ id: '10', name: 'Mango' },
{ id: '11', name: 'Nectarine' },
{ id: '12', name: 'Orange' },
{ id: '13', name: 'Papaya' },
{ id: '14', name: 'Quince' },
{ id: '15', name: 'Raspberry' },
{ id: '16', name: 'Strawberry' },
{ id: '17', name: 'Tangerine' },
{ id: '19', name: 'Vanilla bean' },
{ id: '20', name: 'Watermelon' },
{ id: '22', name: 'Yellow Passion Fruit' },
{ id: '23', name: 'Zucchini' },
{ id: '24', name: 'Cantaloupe' },
{ id: '25', name: 'Blackberry' },
{ id: '26', name: 'Persimmon' },
{ id: '27', name: 'Lychee' },
{ id: '28', name: 'Dragon Fruit' },
{ id: '29', name: 'Passion Fruit' },
{ id: '30', name: 'Starfruit' },
const items: Fruit[] = [
{ id: '1', name: 'Apple', type: 'seedy' },
{ id: '2', name: 'Banana', type: 'longish' },
{ id: '3', name: 'Cherry', type: 'berryish', disabled: true },
{ id: '4', name: 'Date', type: 'stony' },
{ id: '5', name: 'Fig', type: 'seedy', disabled: true },
{ id: '6', name: 'Grape', type: 'berryish' },
{ id: '7', name: 'Honeydew', type: 'chonky' },
{ id: '8', name: 'Kiwi', type: 'seedy' },
{ id: '9', name: 'Lemon', type: 'citrus' },
{ id: '10', name: 'Mango', type: 'stony' },
{ id: '11', name: 'Nectarine', type: 'stony' },
{ id: '12', name: 'Orange', type: 'citrus' },
{ id: '13', name: 'Papaya', type: 'seedy' },
{ id: '14', name: 'Quince', type: 'seedy' },
{ id: '15', name: 'Raspberry', type: 'berryish' },
{ id: '16', name: 'Strawberry', type: 'berryish' },
{ id: '17', name: 'Tangerine', type: 'citrus' },
{ id: '19', name: 'Vanilla bean', type: 'longish' },
{ id: '20', name: 'Watermelon', type: 'chonky' },
{ id: '22', name: 'Yellow Passion Fruit', type: 'seedy' },
{ id: '23', name: 'Zucchini', type: 'longish' },
{ id: '24', name: 'Cantaloupe', type: 'chonky' },
{ id: '25', name: 'Blackberry', type: 'berryish' },
{ id: '26', name: 'Persimmon', type: 'seedy' },
{ id: '27', name: 'Lychee', type: 'berryish' },
{ id: '28', name: 'Dragon Fruit', type: 'seedy' },
{ id: '29', name: 'Passion Fruit', type: 'seedy' },
{ id: '30', name: 'Starfruit', type: 'seedy' },
];
type Fruit = (typeof items)[number];
const getItemId = useCallback((item: Fruit) => item.id, []);
const getIsItemDisabled = useCallback((item: Fruit) => !!item.disabled, []);
@ -66,12 +72,16 @@ const ComboboxDemo: React.FC = () => {
)
: items;
const groupedResults = withGroups
? Object.groupBy(results, (item) => item.type)
: results;
return (
<ComboboxField
label='Favourite fruit'
value={searchValue}
onChange={handleSearchValueChange}
items={results}
items={groupedResults}
getItemId={getItemId}
getIsItemDisabled={getIsItemDisabled}
onSelectItem={selectFruit}
@ -90,15 +100,23 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Example: Story = {
args: {
// Adding these types to keep TS happy, they're not passed on to `ComboboxDemo`
label: '',
value: '',
onChange: () => undefined,
items: [],
getItemId: () => '',
renderItem: () => <>Nothing</>,
onSelectItem: () => undefined,
},
// These args are just used to keep TS happy,
// they're not passed to `ComboboxDemo`
const dummyArgs = {
label: '',
value: '',
onChange: () => undefined,
items: [],
getItemId: () => '',
renderItem: () => <>Nothing</>,
onSelectItem: () => undefined,
};
export const Simple: Story = {
args: dummyArgs,
};
export const WithGroups: Story = {
render: () => <ComboboxDemo withGroups />,
args: dummyArgs,
};

View File

@ -1,4 +1,11 @@
import { forwardRef, useCallback, useId, useRef, useState } from 'react';
import {
forwardRef,
useCallback,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { useIntl } from 'react-intl';
@ -28,10 +35,10 @@ export interface ComboboxItemState {
isDisabled: boolean;
}
interface ComboboxProps<T extends ComboboxItem> extends Omit<
TextInputProps,
'icon'
> {
interface ComboboxProps<
Item extends ComboboxItem,
GroupKey extends string,
> extends Omit<TextInputProps, 'icon'> {
/**
* The value of the combobox's text input
*/
@ -46,34 +53,44 @@ interface ComboboxProps<T extends ComboboxItem> extends Omit<
*/
isLoading?: boolean;
/**
* The set of options/suggestions that should be rendered in the dropdown menu.
* The set of options/suggestions that should be rendered in the dropdown menu,
* optionally separated into groups by providing an object
*/
items: T[];
items: Item[] | Partial<Record<GroupKey, Item[]>>;
/**
* A function that must return a unique id for each option passed via `items`
*/
getItemId?: (item: T) => string;
getItemId?: (item: Item) => string;
/**
* Providing this function turns the combobox into a multi-select box that assumes
* multiple options to be selectable. Single-selection is handled automatically.
*/
getIsItemSelected?: (item: T) => boolean;
getIsItemSelected?: (item: Item) => boolean;
/**
* Use this function to mark items as disabled, if needed
*/
getIsItemDisabled?: (item: T) => boolean;
getIsItemDisabled?: (item: Item) => boolean;
/**
* Customise the rendering of each option.
* The rendered content must not contain other interactive content!
*/
renderItem: (
item: T,
item: Item,
state: ComboboxItemState,
) => React.ReactElement | string;
/**
* Customise the rendering of group titles.
* The `titleId` must be attached to the element that provides the
* accessible name for the group.
*/
renderGroupTitle?: (
groupKey: GroupKey,
titleId: string,
) => React.ReactElement | string;
/**
* The main selection handler, called when an option is selected or deselected.
*/
onSelectItem: (item: T) => void;
onSelectItem: (item: Item) => void;
/**
* Icon to be displayed in the text input
*/
@ -88,8 +105,8 @@ interface ComboboxProps<T extends ComboboxItem> extends Omit<
suppressMenu?: boolean;
}
interface Props<T extends ComboboxItem>
extends ComboboxProps<T>, CommonFieldWrapperProps {}
interface Props<Item extends ComboboxItem, GroupKey extends string>
extends ComboboxProps<Item, GroupKey>, CommonFieldWrapperProps {}
/**
* The combobox field allows users to select one or more items
@ -100,8 +117,11 @@ interface Props<T extends ComboboxItem>
* [research & implementations](https://sarahmhigley.com/writing/select-your-poison/).
*/
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
{ id, label, hint, status, required, ...otherProps }: Props<T>,
export const ComboboxFieldWithRef = <
Item extends ComboboxItem,
GroupKey extends string,
>(
{ id, label, hint, status, required, ...otherProps }: Props<Item, GroupKey>,
ref: React.ForwardedRef<HTMLInputElement>,
) => (
<FormFieldWrapper
@ -118,15 +138,17 @@ export const ComboboxFieldWithRef = <T extends ComboboxItem>(
// Using a type assertion to maintain the full type signature of ComboboxWithRef
// (including its generic type) after wrapping it with `forwardRef`.
export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
<T extends ComboboxItem>(
props: Props<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
<Item extends ComboboxItem, GroupKey extends string>(
props: Props<Item, GroupKey> & {
ref?: React.ForwardedRef<HTMLInputElement>;
},
): ReturnType<typeof ComboboxFieldWithRef>;
displayName: string;
};
ComboboxField.displayName = 'ComboboxField';
const ComboboxWithRef = <T extends ComboboxItem>(
const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
{
value,
isLoading = false,
@ -135,6 +157,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
getIsItemDisabled,
getIsItemSelected,
disabled,
renderGroupTitle,
renderItem,
onSelectItem,
onChange,
@ -144,7 +167,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
icon = SearchIcon,
className,
...otherProps
}: ComboboxProps<T>,
}: ComboboxProps<Item, GroupKey>,
ref: React.ForwardedRef<HTMLInputElement>,
) => {
const intl = useIntl();
@ -157,15 +180,23 @@ const ComboboxWithRef = <T extends ComboboxItem>(
);
const [shouldMenuOpen, setShouldMenuOpen] = useState(false);
const hasGroups = !Array.isArray(items);
const flatItems = useMemo(
() => (hasGroups ? (Object.values(items).flat() as Item[]) : items),
[hasGroups, items],
);
const statusMessage = useGetA11yStatusMessage({
value,
isLoading,
itemCount: items.length,
itemCount: flatItems.length,
});
const showStatusMessageInMenu =
!!statusMessage && value.length > 0 && items.length === 0;
!!statusMessage && value.length > 0 && flatItems.length === 0;
const hasMenuContent =
!disabled && !suppressMenu && (items.length > 0 || showStatusMessageInMenu);
!disabled &&
!suppressMenu &&
(flatItems.length > 0 || showStatusMessageInMenu);
const isMenuOpen = shouldMenuOpen && hasMenuContent;
const openMenu = useCallback(() => {
@ -178,10 +209,10 @@ const ComboboxWithRef = <T extends ComboboxItem>(
}, []);
const resetHighlight = useCallback(() => {
const firstItem = items[0];
const firstItem = flatItems[0];
const firstItemId = firstItem ? getItemId(firstItem) : null;
setHighlightedItemId(firstItemId);
}, [getItemId, items]);
}, [getItemId, flatItems]);
const highlightItem = useCallback((id: string | null) => {
setHighlightedItemId(id);
@ -216,7 +247,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
const selectItem = useCallback(
(itemId: string | null) => {
const item = items.find((item) => item.id === itemId);
const item = flatItems.find((item) => item.id === itemId);
if (item) {
const isDisabled = getIsItemDisabled?.(item) ?? false;
if (!isDisabled) {
@ -229,7 +260,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
}
inputRef.current?.focus();
},
[closeMenu, closeOnSelect, getIsItemDisabled, items, onSelectItem],
[closeMenu, closeOnSelect, getIsItemDisabled, flatItems, onSelectItem],
);
const handleSelectItem = useCallback(
@ -246,38 +277,38 @@ const ComboboxWithRef = <T extends ComboboxItem>(
const moveHighlight = useCallback(
(direction: number) => {
if (items.length === 0) {
if (flatItems.length === 0) {
return;
}
const highlightedItemIndex = items.findIndex(
const highlightedItemIndex = flatItems.findIndex(
(item) => getItemId(item) === highlightedItemId,
);
if (highlightedItemIndex === -1) {
// If no item is highlighted yet, highlight the first or last
if (direction > 0) {
const firstItem = items.at(0);
const firstItem = flatItems.at(0);
highlightItem(firstItem ? getItemId(firstItem) : null);
} else {
const lastItem = items.at(-1);
const lastItem = flatItems.at(-1);
highlightItem(lastItem ? getItemId(lastItem) : null);
}
} else {
// If there is a highlighted item, select the next or previous item
// and wrap around at the start or end:
let newIndex = highlightedItemIndex + direction;
if (newIndex >= items.length) {
if (newIndex >= flatItems.length) {
newIndex = 0;
} else if (newIndex < 0) {
newIndex = items.length - 1;
newIndex = flatItems.length - 1;
}
const newHighlightedItem = items[newIndex];
const newHighlightedItem = flatItems[newIndex];
highlightItem(
newHighlightedItem ? getItemId(newHighlightedItem) : null,
);
}
},
[getItemId, highlightItem, highlightedItemId, items],
[getItemId, highlightItem, highlightedItemId, flatItems],
);
useOnClickOutside(wrapperRef, closeMenu);
@ -331,6 +362,38 @@ const ComboboxWithRef = <T extends ComboboxItem>(
],
);
const renderItems = (items: Item[]) =>
items.map((item) => {
const id = getItemId(item);
const isDisabled = getIsItemDisabled?.(item);
const isHighlighted = id === highlightedItemId;
// If `getIsItemSelected` is defined, we assume 'multi-select'
// behaviour and don't set `aria-selected` based on highlight,
// but based on selected item state.
const isSelected = getIsItemSelected
? getIsItemSelected(item)
: isHighlighted;
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li
key={id}
role='option'
className={classes.menuItem}
data-highlighted={isHighlighted}
aria-selected={isSelected}
aria-disabled={isDisabled}
data-item-id={id}
onMouseEnter={handleItemMouseEnter}
onClick={handleSelectItem}
>
{renderItem(item, {
isSelected,
isDisabled: isDisabled ?? false,
})}
</li>
);
});
const mergeRefs = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
@ -406,42 +469,42 @@ const ComboboxWithRef = <T extends ComboboxItem>(
>
{({ props, placement }) => (
<div {...props} className={classNames(classes.popover, placement)}>
{showStatusMessageInMenu ? (
<span className={classes.emptyMessage}>{statusMessage}</span>
) : (
<ul role='listbox' id={listId} tabIndex={-1}>
{items.map((item) => {
const id = getItemId(item);
const isDisabled = getIsItemDisabled?.(item);
const isHighlighted = id === highlightedItemId;
// If `getIsItemSelected` is defined, we assume 'multi-select'
// behaviour and don't set `aria-selected` based on highlight,
// but based on selected item state.
const isSelected = getIsItemSelected
? getIsItemSelected(item)
: isHighlighted;
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li
key={id}
role='option'
className={classes.menuItem}
data-highlighted={isHighlighted}
aria-selected={isSelected}
aria-disabled={isDisabled}
data-item-id={id}
onMouseEnter={handleItemMouseEnter}
onClick={handleSelectItem}
>
{renderItem(item, {
isSelected,
isDisabled: isDisabled ?? false,
})}
</li>
);
})}
</ul>
)}
<StatusMessageWrapper
showStatus={showStatusMessageInMenu}
status={statusMessage}
>
{hasGroups ? (
<div role='listbox' id={listId} tabIndex={-1}>
{(Object.keys(items) as GroupKey[]).map((groupKey) => {
const groupItems = items[groupKey];
const groupTitleId = `${listId}-group-${groupKey}`;
const groupTitle = renderGroupTitle?.(
groupKey,
groupTitleId,
) ?? <span id={groupTitleId}>{groupKey}</span>;
if (!groupItems?.length) return null;
return (
<ul
key={groupKey}
role='group'
aria-labelledby={groupTitleId}
>
<li role='presentation' className={classes.groupLabel}>
{groupTitle}
</li>
{renderItems(groupItems)}
</ul>
);
})}
</div>
) : (
<ul role='listbox' id={listId} tabIndex={-1}>
{renderItems(items)}
</ul>
)}
</StatusMessageWrapper>
</div>
)}
</Overlay>
@ -452,14 +515,28 @@ const ComboboxWithRef = <T extends ComboboxItem>(
// Using a type assertion to maintain the full type signature of ComboboxWithRef
// (including its generic type) after wrapping it with `forwardRef`.
export const Combobox = forwardRef(ComboboxWithRef) as {
<T extends ComboboxItem>(
props: ComboboxProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
<Item extends ComboboxItem, GroupKey extends string>(
props: ComboboxProps<Item, GroupKey> & {
ref?: React.ForwardedRef<HTMLInputElement>;
},
): ReturnType<typeof ComboboxWithRef>;
displayName: string;
};
Combobox.displayName = 'Combobox';
const StatusMessageWrapper: React.FC<{
showStatus: boolean;
status: string;
children: React.ReactNode;
}> = ({ showStatus, status, children }) => {
if (showStatus) {
return <span className={classes.emptyMessage}>{status}</span>;
}
return children;
};
function useGetA11yStatusMessage({
itemCount,
value,

View File

@ -4,6 +4,7 @@ import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import type { ApiMutedAccountJSON } from 'flavours/glitch/api_types/accounts';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { AccountListItem } from 'flavours/glitch/components/account_list_item';
import { Avatar } from 'flavours/glitch/components/avatar';
@ -57,13 +58,10 @@ const AddedAccountItem: React.FC<{
return <AccountListItem accountId={accountId} renderButton={renderButton} />;
};
interface SuggestionItem {
id: string;
isDisabled?: boolean;
}
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id }) => {
const account = useAccount(id);
const SuggestedAccountItem: React.FC<{ account: ApiMutedAccountJSON }> = (
props,
) => {
const account = useAccount(props.account.id);
if (!account) return null;
@ -75,12 +73,15 @@ const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id }) => {
);
};
const renderAccountItem = (item: SuggestionItem) => (
<SuggestedAccountItem id={item.id} />
const renderAccountItem = (account: ApiMutedAccountJSON) => (
<SuggestedAccountItem account={account} />
);
const getItemId = (item: SuggestionItem) => item.id;
const getIsItemDisabled = (item: SuggestionItem) => item.isDisabled ?? false;
const getItemId = (account: ApiMutedAccountJSON) => account.id;
// Disable accounts who can't be added to a collection
const getIsItemDisabled = (account: ApiMutedAccountJSON) =>
!['automatic', 'manual'].includes(account.feature_approval.current_user);
export const CollectionAccounts: React.FC<{
collection?: ApiCollectionJSON | null;
@ -120,14 +121,6 @@ export const CollectionAccounts: React.FC<{
filterResults: (account) => !accountIds.includes(account.id),
});
const suggestedItems = suggestedAccounts.map(({ id, feature_approval }) => ({
id,
// Disable accounts who can't be added to a collection
isDisabled: !['automatic', 'manual'].includes(
feature_approval.current_user,
),
}));
const handleSearchValueChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
@ -158,7 +151,7 @@ export const CollectionAccounts: React.FC<{
);
const addAccountItem = useCallback(
(item: SuggestionItem) => {
(item: ApiMutedAccountJSON) => {
dispatch(
updateCollectionEditorField({
field: 'accountIds',
@ -192,7 +185,7 @@ export const CollectionAccounts: React.FC<{
);
const instantAddAccountItem = useCallback(
(item: SuggestionItem) => {
(item: ApiMutedAccountJSON) => {
if (id) {
void dispatch(
addCollectionItem({ collectionId: id, accountId: item.id }),
@ -214,7 +207,7 @@ export const CollectionAccounts: React.FC<{
);
const handleSelectItem = useCallback(
(item: SuggestionItem) => {
(item: ApiMutedAccountJSON) => {
if (isEditMode) {
instantAddAccountItem(item);
} else {
@ -269,7 +262,7 @@ export const CollectionAccounts: React.FC<{
onKeyDown={handleSearchKeyDown}
disabled={hasMaxAccounts}
isLoading={isLoadingSuggestions}
items={suggestedItems}
items={suggestedAccounts}
getItemId={getItemId}
getIsItemDisabled={getIsItemDisabled}
renderItem={renderAccountItem}