[Glitch] Update design of account search dropdown in collection editor

Port 05a1c170c285221860e27cb3239092401c3cca8b to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-04-17 17:13:18 +02:00 committed by Claire
parent fe712fa5af
commit 582a8a0880
8 changed files with 191 additions and 69 deletions

View File

@ -25,7 +25,7 @@
.popover {
z-index: 9999;
box-sizing: border-box;
max-height: max(200px, 30dvh);
max-height: min(320px, 50dvh);
padding: 4px;
border-radius: 4px;
color: var(--color-text-primary);
@ -63,13 +63,7 @@
user-select: none;
&[data-highlighted='true'] {
color: var(--color-text-on-brand-base);
background: var(--color-bg-brand-base);
&[aria-disabled='true'] {
color: var(--color-text-on-disabled);
background: var(--color-bg-disabled);
}
background: var(--color-bg-overlay-highlight);
}
&[aria-disabled='true'] {

View File

@ -82,11 +82,12 @@ interface ComboboxProps<
* Customise the rendering of group titles.
* The `titleId` must be attached to the element that provides the
* accessible name for the group.
* Return `null` to omit rendering the group title.
*/
renderGroupTitle?: (
groupKey: GroupKey,
titleId: string,
) => React.ReactElement | string;
) => React.ReactElement | null;
/**
* The main selection handler, called when an option is selected or deselected.
*/
@ -182,7 +183,12 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
const hasGroups = !Array.isArray(items);
const flatItems = useMemo(
() => (hasGroups ? (Object.values(items).flat() as Item[]) : items),
() =>
hasGroups
? (Object.values(items)
.flat()
.filter((i) => !!i) as Item[])
: items,
[hasGroups, items],
);
@ -478,10 +484,11 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
{(Object.keys(items) as GroupKey[]).map((groupKey) => {
const groupItems = items[groupKey];
const groupTitleId = `${listId}-group-${groupKey}`;
const groupTitle = renderGroupTitle?.(
const customGroupTitle = renderGroupTitle?.(
groupKey,
groupTitleId,
) ?? <span id={groupTitleId}>{groupKey}</span>;
);
const hasTitle = customGroupTitle !== null;
if (!groupItems?.length) return null;
@ -489,11 +496,18 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
<ul
key={groupKey}
role='group'
aria-labelledby={groupTitleId}
aria-labelledby={hasTitle ? groupTitleId : undefined}
>
<li role='presentation' className={classes.groupLabel}>
{groupTitle}
</li>
{hasTitle && (
<li
role='presentation'
className={classes.groupLabel}
>
{customGroupTitle ?? (
<span id={groupTitleId}>{groupKey}</span>
)}
</li>
)}
{renderItems(groupItems)}
</ul>
);

View File

@ -14,8 +14,9 @@ interface WrapperProps extends Omit<
/**
* A basic list item component that can be used as a base for more bespoke list items.
*
* Depending on functionality, use `ListItemButton` or `ListItemLink` as a child of the
* wrapper component.
* Choose the child of the wrapper component based on needed interactivity:
* `ListItemContent` for a non-interactive item, `ListItemButton` or `ListItemLink`
* for interactive items.
*/
export const ListItemWrapper: React.FC<WrapperProps> = ({
icon,
@ -33,10 +34,31 @@ export const ListItemWrapper: React.FC<WrapperProps> = ({
);
};
interface LinkProps extends React.ComponentPropsWithoutRef<typeof Link> {
interface WithSubtitle {
subtitle?: React.ReactNode;
}
interface ContentProps
extends React.ComponentPropsWithoutRef<'h3'>, WithSubtitle {}
export const ListItemContent: React.FC<ContentProps> = ({
subtitle,
children,
...otherProps
}) => {
return (
<>
<h3 className={classes.title} {...otherProps}>
{children}
</h3>
{subtitle && <div className={classes.subtitle}>{subtitle}</div>}
</>
);
};
interface LinkProps
extends React.ComponentPropsWithoutRef<typeof Link>, WithSubtitle {}
export const ListItemLink: React.FC<LinkProps> = ({
subtitle,
children,
@ -44,20 +66,16 @@ export const ListItemLink: React.FC<LinkProps> = ({
...otherProps
}) => {
return (
<>
<h3 className={classes.title}>
<Link className={classNames(className, 'focusable')} {...otherProps}>
{children}
</Link>
</h3>
{subtitle && <div className={classes.subtitle}>{subtitle}</div>}
</>
<ListItemContent subtitle={subtitle}>
<Link className={classNames(className, 'focusable')} {...otherProps}>
{children}
</Link>
</ListItemContent>
);
};
interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
subtitle?: React.ReactNode;
}
interface ButtonProps
extends React.ComponentPropsWithoutRef<'button'>, WithSubtitle {}
export const ListItemButton: React.FC<ButtonProps> = ({
subtitle,
@ -66,17 +84,14 @@ export const ListItemButton: React.FC<ButtonProps> = ({
...otherProps
}) => {
return (
<>
<h3 className={classes.title}>
<button
type='button'
className={classNames(className, 'focusable')}
{...otherProps}
>
{children}
</button>
</h3>
{subtitle && <div className={classes.subtitle}>{subtitle}</div>}
</>
<ListItemContent subtitle={subtitle}>
<button
type='button'
className={classNames(className, 'focusable')}
{...otherProps}
>
{children}
</button>
</ListItemContent>
);
};

View File

@ -7,18 +7,31 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
import { Icon } from '../icon';
import { ListItemWrapper, ListItemButton, ListItemLink } from './index';
import {
ListItemWrapper,
ListItemContent,
ListItemButton,
ListItemLink,
} from './index';
const meta = {
title: 'Components/ListItem',
component: ListItemWrapper,
subcomponents: { ListItemButton, ListItemLink },
subcomponents: { ListItemContent, ListItemButton, ListItemLink },
} satisfies Meta<typeof ListItemWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NonInteractive: Story = {
render: () => (
<ListItemWrapper icon={<Icon icon={VisibilityIcon} id='visibility' />}>
<ListItemContent>View more</ListItemContent>
</ListItemWrapper>
),
};
export const WithButton: Story = {
render: () => (
<ListItemWrapper

View File

@ -65,7 +65,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
const description = audio.getIn(['translation', 'description']) || audio.get('description');
return (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} key='audio'>
{Component => (
<Component
src={audio.get('url')}
@ -87,7 +87,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
const description = video.getIn(['translation', 'description']) || video.get('description');
return (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} key='video'>
{Component => (
<Component
preview={video.get('preview_url')}
@ -108,7 +108,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
);
} else {
return (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} key='gallery'>
{Component => (
<Component
media={mediaAttachments}

View File

@ -10,11 +10,16 @@ import { AccountListItem } from 'flavours/glitch/components/account_list_item';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Button } from 'flavours/glitch/components/button';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { useAccountHandle } from 'flavours/glitch/components/display_name/default';
import { EmptyState } from 'flavours/glitch/components/empty_state';
import {
FormStack,
ComboboxField,
} from 'flavours/glitch/components/form_fields';
import {
ListItemContent,
ListItemWrapper,
} from 'flavours/glitch/components/list_item';
import {
Article,
ItemList,
@ -22,6 +27,7 @@ import {
} from 'flavours/glitch/components/scrollable_list/components';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { useSearchAccounts } from 'flavours/glitch/hooks/useSearchAccounts';
import { domain } from 'flavours/glitch/initial_state';
import {
addCollectionItem,
getCollectionItemIds,
@ -58,25 +64,90 @@ const AddedAccountItem: React.FC<{
return <AccountListItem accountId={accountId} renderButton={renderButton} />;
};
const SuggestedAccountItem: React.FC<{ account: ApiMutedAccountJSON }> = (
props,
) => {
const account = useAccount(props.account.id);
const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
const handle = useAccountHandle(account, domain);
if (!account) return null;
return (
<>
<Avatar account={account} />
<DisplayName account={account} />
</>
<ListItemWrapper
className={classes.suggestion}
icon={<Avatar account={account} size={40} />}
>
<ListItemContent subtitle={handle}>
<DisplayName account={account} variant='simple' />
</ListItemContent>
</ListItemWrapper>
);
};
const renderAccountItem = (account: ApiMutedAccountJSON) => (
<SuggestedAccountItem account={account} />
<SuggestedAccountItem id={account.id} />
);
type GroupKey = 'available' | 'mustFollow' | 'disabled';
function groupSuggestions(accounts: ApiMutedAccountJSON[]) {
const { available, disabled } = Object.groupBy(accounts, (account) => {
if (getIsItemDisabled(account)) {
return 'disabled';
}
// if (account.locked && !relationship?.following) {
// return 'mustFollow';
// }
return 'available';
});
// Returning a new object ensures a fixed property order
return { available, disabled };
}
const renderGroupTitle = (groupKey: GroupKey, titleId: string) => {
if (groupKey === 'available') {
return null;
}
let title: React.ReactElement;
let description: React.ReactElement;
if (groupKey === 'mustFollow') {
title = (
<FormattedMessage
id='collections.suggestions.must_follow'
defaultMessage='Must follow first'
/>
);
description = (
<FormattedMessage
id='collections.suggestions.must_follow_desc'
defaultMessage='These accounts review all follow requests. Followers can add them to collections.'
/>
);
} else {
title = (
<FormattedMessage
id='collections.suggestions.can_not_add'
defaultMessage='Cant be added'
/>
);
description = (
<FormattedMessage
id='collections.suggestions.can_not_add_desc'
defaultMessage='These accounts may have opted out of discovery, or they might be on a server that doesnt support collections.'
/>
);
}
return (
<ListItemWrapper className={classes.suggestionGroup}>
<ListItemContent id={titleId} subtitle={description}>
{title}
</ListItemContent>
</ListItemWrapper>
);
};
const getItemId = (account: ApiMutedAccountJSON) => account.id;
// Disable accounts who can't be added to a collection
@ -262,10 +333,11 @@ export const CollectionAccounts: React.FC<{
onKeyDown={handleSearchKeyDown}
disabled={hasMaxAccounts}
isLoading={isLoadingSuggestions}
items={suggestedAccounts}
items={groupSuggestions(suggestedAccounts)}
getItemId={getItemId}
getIsItemDisabled={getIsItemDisabled}
renderItem={renderAccountItem}
renderGroupTitle={renderGroupTitle}
onSelectItem={handleSelectItem}
status={
hasMaxAccounts

View File

@ -276,18 +276,16 @@ export const CollectionDetails: React.FC = () => {
</FormStack>
<div className={classes.stickyFooter}>
<div className={classes.actionWrapper}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.create_collection'
defaultMessage='Create collection'
/>
)}
</Button>
</div>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.create_collection'
defaultMessage='Create collection'
/>
)}
</Button>
</div>
</form>
);

View File

@ -58,3 +58,19 @@
.scrollableWrapper {
margin-inline: -16px;
}
.suggestion {
padding: 4px 0;
[aria-disabled='true'] & {
opacity: 0.6;
}
}
.suggestionGroup {
padding: 4px 0;
// Undo default group styles:
font-weight: 400;
text-transform: none;
}