[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:
parent
3916132f2f
commit
fe712fa5af
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user