[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;
|
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 {
|
.menuItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -4,40 +4,46 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
|
|
||||||
import { ComboboxField } from './combobox_field';
|
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 [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
const items = [
|
const items: Fruit[] = [
|
||||||
{ id: '1', name: 'Apple' },
|
{ id: '1', name: 'Apple', type: 'seedy' },
|
||||||
{ id: '2', name: 'Banana' },
|
{ id: '2', name: 'Banana', type: 'longish' },
|
||||||
{ id: '3', name: 'Cherry', disabled: true },
|
{ id: '3', name: 'Cherry', type: 'berryish', disabled: true },
|
||||||
{ id: '4', name: 'Date' },
|
{ id: '4', name: 'Date', type: 'stony' },
|
||||||
{ id: '5', name: 'Fig', disabled: true },
|
{ id: '5', name: 'Fig', type: 'seedy', disabled: true },
|
||||||
{ id: '6', name: 'Grape' },
|
{ id: '6', name: 'Grape', type: 'berryish' },
|
||||||
{ id: '7', name: 'Honeydew' },
|
{ id: '7', name: 'Honeydew', type: 'chonky' },
|
||||||
{ id: '8', name: 'Kiwi' },
|
{ id: '8', name: 'Kiwi', type: 'seedy' },
|
||||||
{ id: '9', name: 'Lemon' },
|
{ id: '9', name: 'Lemon', type: 'citrus' },
|
||||||
{ id: '10', name: 'Mango' },
|
{ id: '10', name: 'Mango', type: 'stony' },
|
||||||
{ id: '11', name: 'Nectarine' },
|
{ id: '11', name: 'Nectarine', type: 'stony' },
|
||||||
{ id: '12', name: 'Orange' },
|
{ id: '12', name: 'Orange', type: 'citrus' },
|
||||||
{ id: '13', name: 'Papaya' },
|
{ id: '13', name: 'Papaya', type: 'seedy' },
|
||||||
{ id: '14', name: 'Quince' },
|
{ id: '14', name: 'Quince', type: 'seedy' },
|
||||||
{ id: '15', name: 'Raspberry' },
|
{ id: '15', name: 'Raspberry', type: 'berryish' },
|
||||||
{ id: '16', name: 'Strawberry' },
|
{ id: '16', name: 'Strawberry', type: 'berryish' },
|
||||||
{ id: '17', name: 'Tangerine' },
|
{ id: '17', name: 'Tangerine', type: 'citrus' },
|
||||||
{ id: '19', name: 'Vanilla bean' },
|
{ id: '19', name: 'Vanilla bean', type: 'longish' },
|
||||||
{ id: '20', name: 'Watermelon' },
|
{ id: '20', name: 'Watermelon', type: 'chonky' },
|
||||||
{ id: '22', name: 'Yellow Passion Fruit' },
|
{ id: '22', name: 'Yellow Passion Fruit', type: 'seedy' },
|
||||||
{ id: '23', name: 'Zucchini' },
|
{ id: '23', name: 'Zucchini', type: 'longish' },
|
||||||
{ id: '24', name: 'Cantaloupe' },
|
{ id: '24', name: 'Cantaloupe', type: 'chonky' },
|
||||||
{ id: '25', name: 'Blackberry' },
|
{ id: '25', name: 'Blackberry', type: 'berryish' },
|
||||||
{ id: '26', name: 'Persimmon' },
|
{ id: '26', name: 'Persimmon', type: 'seedy' },
|
||||||
{ id: '27', name: 'Lychee' },
|
{ id: '27', name: 'Lychee', type: 'berryish' },
|
||||||
{ id: '28', name: 'Dragon Fruit' },
|
{ id: '28', name: 'Dragon Fruit', type: 'seedy' },
|
||||||
{ id: '29', name: 'Passion Fruit' },
|
{ id: '29', name: 'Passion Fruit', type: 'seedy' },
|
||||||
{ id: '30', name: 'Starfruit' },
|
{ id: '30', name: 'Starfruit', type: 'seedy' },
|
||||||
];
|
];
|
||||||
type Fruit = (typeof items)[number];
|
|
||||||
|
|
||||||
const getItemId = useCallback((item: Fruit) => item.id, []);
|
const getItemId = useCallback((item: Fruit) => item.id, []);
|
||||||
const getIsItemDisabled = useCallback((item: Fruit) => !!item.disabled, []);
|
const getIsItemDisabled = useCallback((item: Fruit) => !!item.disabled, []);
|
||||||
@ -66,12 +72,16 @@ const ComboboxDemo: React.FC = () => {
|
|||||||
)
|
)
|
||||||
: items;
|
: items;
|
||||||
|
|
||||||
|
const groupedResults = withGroups
|
||||||
|
? Object.groupBy(results, (item) => item.type)
|
||||||
|
: results;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComboboxField
|
<ComboboxField
|
||||||
label='Favourite fruit'
|
label='Favourite fruit'
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={handleSearchValueChange}
|
onChange={handleSearchValueChange}
|
||||||
items={results}
|
items={groupedResults}
|
||||||
getItemId={getItemId}
|
getItemId={getItemId}
|
||||||
getIsItemDisabled={getIsItemDisabled}
|
getIsItemDisabled={getIsItemDisabled}
|
||||||
onSelectItem={selectFruit}
|
onSelectItem={selectFruit}
|
||||||
@ -90,15 +100,23 @@ export default meta;
|
|||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Example: Story = {
|
// These args are just used to keep TS happy,
|
||||||
args: {
|
// they're not passed to `ComboboxDemo`
|
||||||
// Adding these types to keep TS happy, they're not passed on to `ComboboxDemo`
|
const dummyArgs = {
|
||||||
label: '',
|
label: '',
|
||||||
value: '',
|
value: '',
|
||||||
onChange: () => undefined,
|
onChange: () => undefined,
|
||||||
items: [],
|
items: [],
|
||||||
getItemId: () => '',
|
getItemId: () => '',
|
||||||
renderItem: () => <>Nothing</>,
|
renderItem: () => <>Nothing</>,
|
||||||
onSelectItem: () => undefined,
|
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';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
@ -28,10 +35,10 @@ export interface ComboboxItemState {
|
|||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComboboxProps<T extends ComboboxItem> extends Omit<
|
interface ComboboxProps<
|
||||||
TextInputProps,
|
Item extends ComboboxItem,
|
||||||
'icon'
|
GroupKey extends string,
|
||||||
> {
|
> extends Omit<TextInputProps, 'icon'> {
|
||||||
/**
|
/**
|
||||||
* The value of the combobox's text input
|
* The value of the combobox's text input
|
||||||
*/
|
*/
|
||||||
@ -46,34 +53,44 @@ interface ComboboxProps<T extends ComboboxItem> extends Omit<
|
|||||||
*/
|
*/
|
||||||
isLoading?: boolean;
|
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`
|
* 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
|
* Providing this function turns the combobox into a multi-select box that assumes
|
||||||
* multiple options to be selectable. Single-selection is handled automatically.
|
* 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
|
* Use this function to mark items as disabled, if needed
|
||||||
*/
|
*/
|
||||||
getIsItemDisabled?: (item: T) => boolean;
|
getIsItemDisabled?: (item: Item) => boolean;
|
||||||
/**
|
/**
|
||||||
* Customise the rendering of each option.
|
* Customise the rendering of each option.
|
||||||
* The rendered content must not contain other interactive content!
|
* The rendered content must not contain other interactive content!
|
||||||
*/
|
*/
|
||||||
renderItem: (
|
renderItem: (
|
||||||
item: T,
|
item: Item,
|
||||||
state: ComboboxItemState,
|
state: ComboboxItemState,
|
||||||
) => React.ReactElement | string;
|
) => 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.
|
* 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
|
* Icon to be displayed in the text input
|
||||||
*/
|
*/
|
||||||
@ -88,8 +105,8 @@ interface ComboboxProps<T extends ComboboxItem> extends Omit<
|
|||||||
suppressMenu?: boolean;
|
suppressMenu?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<T extends ComboboxItem>
|
interface Props<Item extends ComboboxItem, GroupKey extends string>
|
||||||
extends ComboboxProps<T>, CommonFieldWrapperProps {}
|
extends ComboboxProps<Item, GroupKey>, CommonFieldWrapperProps {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The combobox field allows users to select one or more items
|
* 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/).
|
* [research & implementations](https://sarahmhigley.com/writing/select-your-poison/).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
|
export const ComboboxFieldWithRef = <
|
||||||
{ id, label, hint, status, required, ...otherProps }: Props<T>,
|
Item extends ComboboxItem,
|
||||||
|
GroupKey extends string,
|
||||||
|
>(
|
||||||
|
{ id, label, hint, status, required, ...otherProps }: Props<Item, GroupKey>,
|
||||||
ref: React.ForwardedRef<HTMLInputElement>,
|
ref: React.ForwardedRef<HTMLInputElement>,
|
||||||
) => (
|
) => (
|
||||||
<FormFieldWrapper
|
<FormFieldWrapper
|
||||||
@ -118,15 +138,17 @@ export const ComboboxFieldWithRef = <T extends ComboboxItem>(
|
|||||||
// Using a type assertion to maintain the full type signature of ComboboxWithRef
|
// Using a type assertion to maintain the full type signature of ComboboxWithRef
|
||||||
// (including its generic type) after wrapping it with `forwardRef`.
|
// (including its generic type) after wrapping it with `forwardRef`.
|
||||||
export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
|
export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
|
||||||
<T extends ComboboxItem>(
|
<Item extends ComboboxItem, GroupKey extends string>(
|
||||||
props: Props<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
|
props: Props<Item, GroupKey> & {
|
||||||
|
ref?: React.ForwardedRef<HTMLInputElement>;
|
||||||
|
},
|
||||||
): ReturnType<typeof ComboboxFieldWithRef>;
|
): ReturnType<typeof ComboboxFieldWithRef>;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
ComboboxField.displayName = 'ComboboxField';
|
ComboboxField.displayName = 'ComboboxField';
|
||||||
|
|
||||||
const ComboboxWithRef = <T extends ComboboxItem>(
|
const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
|
||||||
{
|
{
|
||||||
value,
|
value,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@ -135,6 +157,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
getIsItemDisabled,
|
getIsItemDisabled,
|
||||||
getIsItemSelected,
|
getIsItemSelected,
|
||||||
disabled,
|
disabled,
|
||||||
|
renderGroupTitle,
|
||||||
renderItem,
|
renderItem,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onChange,
|
onChange,
|
||||||
@ -144,7 +167,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
icon = SearchIcon,
|
icon = SearchIcon,
|
||||||
className,
|
className,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: ComboboxProps<T>,
|
}: ComboboxProps<Item, GroupKey>,
|
||||||
ref: React.ForwardedRef<HTMLInputElement>,
|
ref: React.ForwardedRef<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@ -157,15 +180,23 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
);
|
);
|
||||||
const [shouldMenuOpen, setShouldMenuOpen] = useState(false);
|
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({
|
const statusMessage = useGetA11yStatusMessage({
|
||||||
value,
|
value,
|
||||||
isLoading,
|
isLoading,
|
||||||
itemCount: items.length,
|
itemCount: flatItems.length,
|
||||||
});
|
});
|
||||||
const showStatusMessageInMenu =
|
const showStatusMessageInMenu =
|
||||||
!!statusMessage && value.length > 0 && items.length === 0;
|
!!statusMessage && value.length > 0 && flatItems.length === 0;
|
||||||
const hasMenuContent =
|
const hasMenuContent =
|
||||||
!disabled && !suppressMenu && (items.length > 0 || showStatusMessageInMenu);
|
!disabled &&
|
||||||
|
!suppressMenu &&
|
||||||
|
(flatItems.length > 0 || showStatusMessageInMenu);
|
||||||
const isMenuOpen = shouldMenuOpen && hasMenuContent;
|
const isMenuOpen = shouldMenuOpen && hasMenuContent;
|
||||||
|
|
||||||
const openMenu = useCallback(() => {
|
const openMenu = useCallback(() => {
|
||||||
@ -178,10 +209,10 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resetHighlight = useCallback(() => {
|
const resetHighlight = useCallback(() => {
|
||||||
const firstItem = items[0];
|
const firstItem = flatItems[0];
|
||||||
const firstItemId = firstItem ? getItemId(firstItem) : null;
|
const firstItemId = firstItem ? getItemId(firstItem) : null;
|
||||||
setHighlightedItemId(firstItemId);
|
setHighlightedItemId(firstItemId);
|
||||||
}, [getItemId, items]);
|
}, [getItemId, flatItems]);
|
||||||
|
|
||||||
const highlightItem = useCallback((id: string | null) => {
|
const highlightItem = useCallback((id: string | null) => {
|
||||||
setHighlightedItemId(id);
|
setHighlightedItemId(id);
|
||||||
@ -216,7 +247,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
|
|
||||||
const selectItem = useCallback(
|
const selectItem = useCallback(
|
||||||
(itemId: string | null) => {
|
(itemId: string | null) => {
|
||||||
const item = items.find((item) => item.id === itemId);
|
const item = flatItems.find((item) => item.id === itemId);
|
||||||
if (item) {
|
if (item) {
|
||||||
const isDisabled = getIsItemDisabled?.(item) ?? false;
|
const isDisabled = getIsItemDisabled?.(item) ?? false;
|
||||||
if (!isDisabled) {
|
if (!isDisabled) {
|
||||||
@ -229,7 +260,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
}
|
}
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
[closeMenu, closeOnSelect, getIsItemDisabled, items, onSelectItem],
|
[closeMenu, closeOnSelect, getIsItemDisabled, flatItems, onSelectItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectItem = useCallback(
|
const handleSelectItem = useCallback(
|
||||||
@ -246,38 +277,38 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
|
|
||||||
const moveHighlight = useCallback(
|
const moveHighlight = useCallback(
|
||||||
(direction: number) => {
|
(direction: number) => {
|
||||||
if (items.length === 0) {
|
if (flatItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const highlightedItemIndex = items.findIndex(
|
const highlightedItemIndex = flatItems.findIndex(
|
||||||
(item) => getItemId(item) === highlightedItemId,
|
(item) => getItemId(item) === highlightedItemId,
|
||||||
);
|
);
|
||||||
if (highlightedItemIndex === -1) {
|
if (highlightedItemIndex === -1) {
|
||||||
// If no item is highlighted yet, highlight the first or last
|
// If no item is highlighted yet, highlight the first or last
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
const firstItem = items.at(0);
|
const firstItem = flatItems.at(0);
|
||||||
highlightItem(firstItem ? getItemId(firstItem) : null);
|
highlightItem(firstItem ? getItemId(firstItem) : null);
|
||||||
} else {
|
} else {
|
||||||
const lastItem = items.at(-1);
|
const lastItem = flatItems.at(-1);
|
||||||
highlightItem(lastItem ? getItemId(lastItem) : null);
|
highlightItem(lastItem ? getItemId(lastItem) : null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If there is a highlighted item, select the next or previous item
|
// If there is a highlighted item, select the next or previous item
|
||||||
// and wrap around at the start or end:
|
// and wrap around at the start or end:
|
||||||
let newIndex = highlightedItemIndex + direction;
|
let newIndex = highlightedItemIndex + direction;
|
||||||
if (newIndex >= items.length) {
|
if (newIndex >= flatItems.length) {
|
||||||
newIndex = 0;
|
newIndex = 0;
|
||||||
} else if (newIndex < 0) {
|
} else if (newIndex < 0) {
|
||||||
newIndex = items.length - 1;
|
newIndex = flatItems.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newHighlightedItem = items[newIndex];
|
const newHighlightedItem = flatItems[newIndex];
|
||||||
highlightItem(
|
highlightItem(
|
||||||
newHighlightedItem ? getItemId(newHighlightedItem) : null,
|
newHighlightedItem ? getItemId(newHighlightedItem) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getItemId, highlightItem, highlightedItemId, items],
|
[getItemId, highlightItem, highlightedItemId, flatItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
useOnClickOutside(wrapperRef, closeMenu);
|
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(
|
const mergeRefs = useCallback(
|
||||||
(element: HTMLInputElement | null) => {
|
(element: HTMLInputElement | null) => {
|
||||||
inputRef.current = element;
|
inputRef.current = element;
|
||||||
@ -406,42 +469,42 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
>
|
>
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
<div {...props} className={classNames(classes.popover, placement)}>
|
<div {...props} className={classNames(classes.popover, placement)}>
|
||||||
{showStatusMessageInMenu ? (
|
<StatusMessageWrapper
|
||||||
<span className={classes.emptyMessage}>{statusMessage}</span>
|
showStatus={showStatusMessageInMenu}
|
||||||
) : (
|
status={statusMessage}
|
||||||
<ul role='listbox' id={listId} tabIndex={-1}>
|
>
|
||||||
{items.map((item) => {
|
{hasGroups ? (
|
||||||
const id = getItemId(item);
|
<div role='listbox' id={listId} tabIndex={-1}>
|
||||||
const isDisabled = getIsItemDisabled?.(item);
|
{(Object.keys(items) as GroupKey[]).map((groupKey) => {
|
||||||
const isHighlighted = id === highlightedItemId;
|
const groupItems = items[groupKey];
|
||||||
// If `getIsItemSelected` is defined, we assume 'multi-select'
|
const groupTitleId = `${listId}-group-${groupKey}`;
|
||||||
// behaviour and don't set `aria-selected` based on highlight,
|
const groupTitle = renderGroupTitle?.(
|
||||||
// but based on selected item state.
|
groupKey,
|
||||||
const isSelected = getIsItemSelected
|
groupTitleId,
|
||||||
? getIsItemSelected(item)
|
) ?? <span id={groupTitleId}>{groupKey}</span>;
|
||||||
: isHighlighted;
|
|
||||||
return (
|
if (!groupItems?.length) return null;
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
|
||||||
<li
|
return (
|
||||||
key={id}
|
<ul
|
||||||
role='option'
|
key={groupKey}
|
||||||
className={classes.menuItem}
|
role='group'
|
||||||
data-highlighted={isHighlighted}
|
aria-labelledby={groupTitleId}
|
||||||
aria-selected={isSelected}
|
>
|
||||||
aria-disabled={isDisabled}
|
<li role='presentation' className={classes.groupLabel}>
|
||||||
data-item-id={id}
|
{groupTitle}
|
||||||
onMouseEnter={handleItemMouseEnter}
|
</li>
|
||||||
onClick={handleSelectItem}
|
{renderItems(groupItems)}
|
||||||
>
|
</ul>
|
||||||
{renderItem(item, {
|
);
|
||||||
isSelected,
|
})}
|
||||||
isDisabled: isDisabled ?? false,
|
</div>
|
||||||
})}
|
) : (
|
||||||
</li>
|
<ul role='listbox' id={listId} tabIndex={-1}>
|
||||||
);
|
{renderItems(items)}
|
||||||
})}
|
</ul>
|
||||||
</ul>
|
)}
|
||||||
)}
|
</StatusMessageWrapper>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Overlay>
|
</Overlay>
|
||||||
@ -452,14 +515,28 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||||||
// Using a type assertion to maintain the full type signature of ComboboxWithRef
|
// Using a type assertion to maintain the full type signature of ComboboxWithRef
|
||||||
// (including its generic type) after wrapping it with `forwardRef`.
|
// (including its generic type) after wrapping it with `forwardRef`.
|
||||||
export const Combobox = forwardRef(ComboboxWithRef) as {
|
export const Combobox = forwardRef(ComboboxWithRef) as {
|
||||||
<T extends ComboboxItem>(
|
<Item extends ComboboxItem, GroupKey extends string>(
|
||||||
props: ComboboxProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
|
props: ComboboxProps<Item, GroupKey> & {
|
||||||
|
ref?: React.ForwardedRef<HTMLInputElement>;
|
||||||
|
},
|
||||||
): ReturnType<typeof ComboboxWithRef>;
|
): ReturnType<typeof ComboboxWithRef>;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
Combobox.displayName = 'Combobox';
|
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({
|
function useGetA11yStatusMessage({
|
||||||
itemCount,
|
itemCount,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { FormattedMessage, useIntl } from 'react-intl';
|
|||||||
|
|
||||||
import { useHistory } from 'react-router-dom';
|
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 type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
||||||
import { AccountListItem } from 'flavours/glitch/components/account_list_item';
|
import { AccountListItem } from 'flavours/glitch/components/account_list_item';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
@ -57,13 +58,10 @@ const AddedAccountItem: React.FC<{
|
|||||||
return <AccountListItem accountId={accountId} renderButton={renderButton} />;
|
return <AccountListItem accountId={accountId} renderButton={renderButton} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SuggestionItem {
|
const SuggestedAccountItem: React.FC<{ account: ApiMutedAccountJSON }> = (
|
||||||
id: string;
|
props,
|
||||||
isDisabled?: boolean;
|
) => {
|
||||||
}
|
const account = useAccount(props.account.id);
|
||||||
|
|
||||||
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id }) => {
|
|
||||||
const account = useAccount(id);
|
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
@ -75,12 +73,15 @@ const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAccountItem = (item: SuggestionItem) => (
|
const renderAccountItem = (account: ApiMutedAccountJSON) => (
|
||||||
<SuggestedAccountItem id={item.id} />
|
<SuggestedAccountItem account={account} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const getItemId = (item: SuggestionItem) => item.id;
|
const getItemId = (account: ApiMutedAccountJSON) => account.id;
|
||||||
const getIsItemDisabled = (item: SuggestionItem) => item.isDisabled ?? false;
|
|
||||||
|
// 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<{
|
export const CollectionAccounts: React.FC<{
|
||||||
collection?: ApiCollectionJSON | null;
|
collection?: ApiCollectionJSON | null;
|
||||||
@ -120,14 +121,6 @@ export const CollectionAccounts: React.FC<{
|
|||||||
filterResults: (account) => !accountIds.includes(account.id),
|
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(
|
const handleSearchValueChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchValue(e.target.value);
|
setSearchValue(e.target.value);
|
||||||
@ -158,7 +151,7 @@ export const CollectionAccounts: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const addAccountItem = useCallback(
|
const addAccountItem = useCallback(
|
||||||
(item: SuggestionItem) => {
|
(item: ApiMutedAccountJSON) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
updateCollectionEditorField({
|
updateCollectionEditorField({
|
||||||
field: 'accountIds',
|
field: 'accountIds',
|
||||||
@ -192,7 +185,7 @@ export const CollectionAccounts: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const instantAddAccountItem = useCallback(
|
const instantAddAccountItem = useCallback(
|
||||||
(item: SuggestionItem) => {
|
(item: ApiMutedAccountJSON) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
void dispatch(
|
void dispatch(
|
||||||
addCollectionItem({ collectionId: id, accountId: item.id }),
|
addCollectionItem({ collectionId: id, accountId: item.id }),
|
||||||
@ -214,7 +207,7 @@ export const CollectionAccounts: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectItem = useCallback(
|
const handleSelectItem = useCallback(
|
||||||
(item: SuggestionItem) => {
|
(item: ApiMutedAccountJSON) => {
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
instantAddAccountItem(item);
|
instantAddAccountItem(item);
|
||||||
} else {
|
} else {
|
||||||
@ -269,7 +262,7 @@ export const CollectionAccounts: React.FC<{
|
|||||||
onKeyDown={handleSearchKeyDown}
|
onKeyDown={handleSearchKeyDown}
|
||||||
disabled={hasMaxAccounts}
|
disabled={hasMaxAccounts}
|
||||||
isLoading={isLoadingSuggestions}
|
isLoading={isLoadingSuggestions}
|
||||||
items={suggestedItems}
|
items={suggestedAccounts}
|
||||||
getItemId={getItemId}
|
getItemId={getItemId}
|
||||||
getIsItemDisabled={getIsItemDisabled}
|
getIsItemDisabled={getIsItemDisabled}
|
||||||
renderItem={renderAccountItem}
|
renderItem={renderAccountItem}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user