Implement final design for collection editor account dropdown menu (#38767)

This commit is contained in:
diondiondion 2026-04-21 16:12:44 +02:00 committed by GitHub
parent 57c5d1c8dd
commit a706fce678
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 121 additions and 55 deletions

View File

@ -26,7 +26,6 @@
z-index: 9999;
box-sizing: border-box;
max-height: min(320px, 50dvh);
padding: 4px;
border-radius: 4px;
color: var(--color-text-primary);
background: var(--color-bg-primary);
@ -38,22 +37,18 @@
overscroll-behavior-y: contain;
}
.groupLabel {
padding-block: 12px 4px;
padding-inline: 12px;
.groupTitle {
padding: 8px 16px 4px;
font-size: 13px;
font-weight: bold;
text-transform: uppercase;
ul:first-child > & {
padding-top: 4px;
}
}
.menuItem {
display: flex;
align-items: center;
padding: 8px 12px;
margin-inline: 4px;
gap: 12px;
font-size: 14px;
line-height: 20px;
@ -62,6 +57,14 @@
cursor: pointer;
user-select: none;
&:first-child {
margin-top: 4px;
}
&:last-child {
margin-bottom: 4px;
}
&[data-highlighted='true'] {
background: var(--color-bg-overlay-highlight);
}

View File

@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ComboboxField } from './combobox_field';
import { ComboboxField, ComboboxMenuItem } from './combobox_field';
interface Fruit {
id: string;
@ -60,7 +60,7 @@ const ComboboxDemo: React.FC<{ withGroups?: boolean }> = ({ withGroups }) => {
}, []);
const renderItem = useCallback(
(fruit: Fruit) => <span>{fruit.name}</span>,
(fruit: Fruit) => <ComboboxMenuItem>{fruit.name}</ComboboxMenuItem>,
[],
);
@ -93,6 +93,7 @@ const ComboboxDemo: React.FC<{ withGroups?: boolean }> = ({ withGroups }) => {
const meta = {
title: 'Components/Form Fields/ComboboxField',
component: ComboboxField,
subcomponents: { ComboboxMenuItem },
render: () => <ComboboxDemo />,
} satisfies Meta<typeof ComboboxField>;

View File

@ -1,6 +1,8 @@
import {
createContext,
forwardRef,
useCallback,
useContext,
useId,
useMemo,
useRef,
@ -109,6 +111,58 @@ interface ComboboxProps<
interface Props<Item extends ComboboxItem, GroupKey extends string>
extends ComboboxProps<Item, GroupKey>, CommonFieldWrapperProps {}
interface ComboboxItemPropsContext {
role: 'option';
'data-highlighted': boolean;
'aria-selected': boolean;
'aria-disabled': boolean;
'data-item-id': string;
onMouseEnter: React.MouseEventHandler<HTMLLIElement>;
onClick: React.MouseEventHandler<HTMLLIElement>;
}
const ComboboxItemPropsContext = createContext<ComboboxItemPropsContext | null>(
null,
);
export function useComboboxItemProps() {
const context = useContext(ComboboxItemPropsContext);
if (context === null) {
throw new Error(
'useComboboxItemProps must be used within a Combobox component',
);
}
return context;
}
export const ComboboxMenuItem: React.FC<{
className?: string;
children: React.ReactNode;
}> = ({ className, children }) => {
const props = useComboboxItemProps();
return (
<li className={classNames(className, classes.menuItem)} {...props}>
{children}
</li>
);
};
export const ComboboxMenuGroupTitle: React.FC<
React.ComponentPropsWithoutRef<'li'>
> = ({ className, children, ...otherProps }) => {
return (
<li
{...otherProps}
role='presentation'
className={classNames(className, classes.groupTitle)}
>
{children}
</li>
);
};
/**
* The combobox field allows users to select one or more items
* by searching or filtering a large or dynamic list of options.
@ -371,7 +425,7 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
const renderItems = (items: Item[]) =>
items.map((item) => {
const id = getItemId(item);
const isDisabled = getIsItemDisabled?.(item);
const isDisabled = getIsItemDisabled?.(item) ?? false;
const isHighlighted = id === highlightedItemId;
// If `getIsItemSelected` is defined, we assume 'multi-select'
// behaviour and don't set `aria-selected` based on highlight,
@ -380,23 +434,23 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
? getIsItemSelected(item)
: isHighlighted;
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li
<ComboboxItemPropsContext.Provider
key={id}
role='option'
className={classes.menuItem}
data-highlighted={isHighlighted}
aria-selected={isSelected}
aria-disabled={isDisabled}
data-item-id={id}
onMouseEnter={handleItemMouseEnter}
onClick={handleSelectItem}
value={{
role: 'option',
'data-highlighted': isHighlighted,
'aria-selected': isSelected,
'aria-disabled': isDisabled,
'data-item-id': id,
onMouseEnter: handleItemMouseEnter,
onClick: handleSelectItem,
}}
>
{renderItem(item, {
isSelected,
isDisabled: isDisabled ?? false,
isDisabled,
})}
</li>
</ComboboxItemPropsContext.Provider>
);
});
@ -498,16 +552,12 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
role='group'
aria-labelledby={hasTitle ? groupTitleId : undefined}
>
{hasTitle && (
<li
role='presentation'
className={classes.groupLabel}
>
{customGroupTitle ?? (
<span id={groupTitleId}>{groupKey}</span>
)}
</li>
)}
{hasTitle &&
(customGroupTitle ?? (
<ComboboxMenuGroupTitle id={groupTitleId}>
{groupKey}
</ComboboxMenuGroupTitle>
))}
{renderItems(groupItems)}
</ul>
);

View File

@ -4,6 +4,7 @@ import { useCallback, useId, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Combobox } from '@/mastodon/components/form_fields';
import { ComboboxMenuItem } from '@/mastodon/components/form_fields/combobox_field';
import { useSearchTags } from '@/mastodon/hooks/useSearchTags';
import type { TagSearchResult } from '@/mastodon/hooks/useSearchTags';
import { addFeaturedTags } from '@/mastodon/reducers/slices/profile_edit';
@ -77,4 +78,6 @@ export const AccountEditTagSearch: FC = () => {
);
};
const renderItem = (item: TagSearchResult) => item.label ?? `#${item.name}`;
const renderItem = (item: TagSearchResult) => (
<ComboboxMenuItem>{item.label ?? `#${item.name}`}</ComboboxMenuItem>
);

View File

@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable';
import { useComboboxItemProps } from '@/mastodon/components/form_fields/combobox_field';
import type { ApiMutedAccountJSON } from 'mastodon/api_types/accounts';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { AccountListItem } from 'mastodon/components/account_list_item';
@ -67,18 +68,18 @@ const AddedAccountItem: React.FC<{
const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
const handle = useAccountHandle(account, domain);
const comboboxItemProps = useComboboxItemProps();
if (!account) return null;
return (
<ListItemWrapper
className={classes.suggestion}
icon={<Avatar account={account} size={40} />}
>
<ListItemContent subtitle={handle}>
<DisplayName account={account} variant='simple' />
</ListItemContent>
</ListItemWrapper>
<li {...comboboxItemProps} className={classes.suggestion}>
<ListItemWrapper icon={<Avatar account={account} size={40} />}>
<ListItemContent subtitle={handle}>
<DisplayName account={account} variant='simple' />
</ListItemContent>
</ListItemWrapper>
</li>
);
};
@ -158,11 +159,13 @@ const renderGroupTitle = (groupKey: GroupKey, titleId: string) => {
}
return (
<ListItemWrapper className={classes.suggestionGroup}>
<ListItemContent id={titleId} subtitle={description}>
{title}
</ListItemContent>
</ListItemWrapper>
<li role='presentation'>
<ListItemWrapper className={classes.suggestionGroup}>
<ListItemContent id={titleId} subtitle={description}>
{title}
</ListItemContent>
</ListItemWrapper>
</li>
);
};

View File

@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import { ComboboxMenuItem } from '@/mastodon/components/form_fields/combobox_field';
import { languages } from '@/mastodon/initial_state';
import {
hasSpecialCharacters,
@ -373,7 +374,9 @@ const TopicField: React.FC = () => {
);
};
const renderTagItem = (item: TagSearchResult) => item.label ?? `#${item.name}`;
const renderTagItem = (item: TagSearchResult) => (
<ComboboxMenuItem>{item.label ?? `#${item.name}`}</ComboboxMenuItem>
);
const LanguageField: React.FC = () => {
const dispatch = useAppDispatch();

View File

@ -60,17 +60,20 @@
}
.suggestion {
padding: 4px 0;
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--color-border-primary);
[aria-disabled='true'] & {
&[data-highlighted='true'] {
background: var(--color-bg-overlay-highlight);
}
&[aria-disabled='true'] {
opacity: 0.6;
cursor: not-allowed;
}
}
.suggestionGroup {
padding: 4px 0;
// Undo default group styles:
font-weight: 400;
text-transform: none;
padding-bottom: 4px;
}