From a706fce6781104b3aa1315be14f1fb8ccd4fcb1b Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 21 Apr 2026 16:12:44 +0200 Subject: [PATCH] Implement final design for collection editor account dropdown menu (#38767) --- .../form_fields/combobox.module.scss | 19 ++-- .../form_fields/combobox_field.stories.tsx | 5 +- .../components/form_fields/combobox_field.tsx | 96 ++++++++++++++----- .../account_edit/components/tag_search.tsx | 5 +- .../features/collections/editor/accounts.tsx | 29 +++--- .../features/collections/editor/details.tsx | 5 +- .../collections/editor/styles.module.scss | 17 ++-- 7 files changed, 121 insertions(+), 55 deletions(-) diff --git a/app/javascript/mastodon/components/form_fields/combobox.module.scss b/app/javascript/mastodon/components/form_fields/combobox.module.scss index ef230a77b8..c14bac0cb6 100644 --- a/app/javascript/mastodon/components/form_fields/combobox.module.scss +++ b/app/javascript/mastodon/components/form_fields/combobox.module.scss @@ -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); } diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx index 32fc736423..795cba727a 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx @@ -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) => {fruit.name}, + (fruit: Fruit) => {fruit.name}, [], ); @@ -93,6 +93,7 @@ const ComboboxDemo: React.FC<{ withGroups?: boolean }> = ({ withGroups }) => { const meta = { title: 'Components/Form Fields/ComboboxField', component: ComboboxField, + subcomponents: { ComboboxMenuItem }, render: () => , } satisfies Meta; diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.tsx index 64cbb3ce8a..40199d47ed 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -1,6 +1,8 @@ import { + createContext, forwardRef, useCallback, + useContext, useId, useMemo, useRef, @@ -109,6 +111,58 @@ interface ComboboxProps< interface Props extends ComboboxProps, CommonFieldWrapperProps {} +interface ComboboxItemPropsContext { + role: 'option'; + 'data-highlighted': boolean; + 'aria-selected': boolean; + 'aria-disabled': boolean; + 'data-item-id': string; + onMouseEnter: React.MouseEventHandler; + onClick: React.MouseEventHandler; +} + +const ComboboxItemPropsContext = createContext( + 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 ( +
  • + {children} +
  • + ); +}; + +export const ComboboxMenuGroupTitle: React.FC< + React.ComponentPropsWithoutRef<'li'> +> = ({ className, children, ...otherProps }) => { + return ( +
  • + {children} +
  • + ); +}; + /** * 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 = ( 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 = ( ? getIsItemSelected(item) : isHighlighted; return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events -
  • {renderItem(item, { isSelected, - isDisabled: isDisabled ?? false, + isDisabled, })} -
  • + ); }); @@ -498,16 +552,12 @@ const ComboboxWithRef = ( role='group' aria-labelledby={hasTitle ? groupTitleId : undefined} > - {hasTitle && ( -
  • - {customGroupTitle ?? ( - {groupKey} - )} -
  • - )} + {hasTitle && + (customGroupTitle ?? ( + + {groupKey} + + ))} {renderItems(groupItems)} ); diff --git a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx index 2dd17c2a72..04befb4690 100644 --- a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx +++ b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx @@ -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) => ( + {item.label ?? `#${item.name}`} +); diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index a51f58c236..dddd0242e0 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -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 ( - } - > - - - - +
  • + }> + + + + +
  • ); }; @@ -158,11 +159,13 @@ const renderGroupTitle = (groupKey: GroupKey, titleId: string) => { } return ( - - - {title} - - +
  • + + + {title} + + +
  • ); }; diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index ae944ba41f..42ef8db970 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -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) => ( + {item.label ?? `#${item.name}`} +); const LanguageField: React.FC = () => { const dispatch = useAppDispatch(); diff --git a/app/javascript/mastodon/features/collections/editor/styles.module.scss b/app/javascript/mastodon/features/collections/editor/styles.module.scss index 1befc06cc1..d4b1d2d7f3 100644 --- a/app/javascript/mastodon/features/collections/editor/styles.module.scss +++ b/app/javascript/mastodon/features/collections/editor/styles.module.scss @@ -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; }