diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss index 732893200c..ca7bc2020b 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss @@ -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; diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx index 2c4b82fdcd..32fc736423 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx @@ -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 ( ; -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: () => , + args: dummyArgs, }; diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx index bc7bf44f69..3ddd8e559e 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx @@ -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 extends Omit< - TextInputProps, - 'icon' -> { +interface ComboboxProps< + Item extends ComboboxItem, + GroupKey extends string, +> extends Omit { /** * The value of the combobox's text input */ @@ -46,34 +53,44 @@ interface ComboboxProps 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>; /** * 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 extends Omit< suppressMenu?: boolean; } -interface Props - extends ComboboxProps, CommonFieldWrapperProps {} +interface Props + extends ComboboxProps, CommonFieldWrapperProps {} /** * The combobox field allows users to select one or more items @@ -100,8 +117,11 @@ interface Props * [research & implementations](https://sarahmhigley.com/writing/select-your-poison/). */ -export const ComboboxFieldWithRef = ( - { id, label, hint, status, required, ...otherProps }: Props, +export const ComboboxFieldWithRef = < + Item extends ComboboxItem, + GroupKey extends string, +>( + { id, label, hint, status, required, ...otherProps }: Props, ref: React.ForwardedRef, ) => ( ( // 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 { - ( - props: Props & { ref?: React.ForwardedRef }, + ( + props: Props & { + ref?: React.ForwardedRef; + }, ): ReturnType; displayName: string; }; ComboboxField.displayName = 'ComboboxField'; -const ComboboxWithRef = ( +const ComboboxWithRef = ( { value, isLoading = false, @@ -135,6 +157,7 @@ const ComboboxWithRef = ( getIsItemDisabled, getIsItemSelected, disabled, + renderGroupTitle, renderItem, onSelectItem, onChange, @@ -144,7 +167,7 @@ const ComboboxWithRef = ( icon = SearchIcon, className, ...otherProps - }: ComboboxProps, + }: ComboboxProps, ref: React.ForwardedRef, ) => { const intl = useIntl(); @@ -157,15 +180,23 @@ const ComboboxWithRef = ( ); 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 = ( }, []); 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 = ( 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 = ( } inputRef.current?.focus(); }, - [closeMenu, closeOnSelect, getIsItemDisabled, items, onSelectItem], + [closeMenu, closeOnSelect, getIsItemDisabled, flatItems, onSelectItem], ); const handleSelectItem = useCallback( @@ -246,38 +277,38 @@ const ComboboxWithRef = ( 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 = ( ], ); + 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 +
  • + {renderItem(item, { + isSelected, + isDisabled: isDisabled ?? false, + })} +
  • + ); + }); + const mergeRefs = useCallback( (element: HTMLInputElement | null) => { inputRef.current = element; @@ -406,42 +469,42 @@ const ComboboxWithRef = ( > {({ props, placement }) => (
    - {showStatusMessageInMenu ? ( - {statusMessage} - ) : ( -
      - {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 -
    • - {renderItem(item, { - isSelected, - isDisabled: isDisabled ?? false, - })} -
    • - ); - })} -
    - )} + + {hasGroups ? ( +
    + {(Object.keys(items) as GroupKey[]).map((groupKey) => { + const groupItems = items[groupKey]; + const groupTitleId = `${listId}-group-${groupKey}`; + const groupTitle = renderGroupTitle?.( + groupKey, + groupTitleId, + ) ?? {groupKey}; + + if (!groupItems?.length) return null; + + return ( +
      +
    • + {groupTitle} +
    • + {renderItems(groupItems)} +
    + ); + })} +
    + ) : ( +
      + {renderItems(items)} +
    + )} +
    )} @@ -452,14 +515,28 @@ const ComboboxWithRef = ( // 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 { - ( - props: ComboboxProps & { ref?: React.ForwardedRef }, + ( + props: ComboboxProps & { + ref?: React.ForwardedRef; + }, ): ReturnType; displayName: string; }; Combobox.displayName = 'Combobox'; +const StatusMessageWrapper: React.FC<{ + showStatus: boolean; + status: string; + children: React.ReactNode; +}> = ({ showStatus, status, children }) => { + if (showStatus) { + return {status}; + } + + return children; +}; + function useGetA11yStatusMessage({ itemCount, value, diff --git a/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx b/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx index 879e46c241..5e512212d5 100644 --- a/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx +++ b/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx @@ -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 ; }; -interface SuggestionItem { - id: string; - isDisabled?: boolean; -} - -const SuggestedAccountItem: React.FC = ({ 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 = ({ id }) => { ); }; -const renderAccountItem = (item: SuggestionItem) => ( - +const renderAccountItem = (account: ApiMutedAccountJSON) => ( + ); -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) => { 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}