Update design of account search dropdown in collection editor (#38739)
This commit is contained in:
parent
ea33d7fba6
commit
05a1c170c2
@ -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'] {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,8 +10,13 @@ import { AccountListItem } from 'mastodon/components/account_list_item';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { useAccountHandle } from 'mastodon/components/display_name/default';
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
|
||||
import {
|
||||
ListItemContent,
|
||||
ListItemWrapper,
|
||||
} from 'mastodon/components/list_item';
|
||||
import {
|
||||
Article,
|
||||
ItemList,
|
||||
@ -19,6 +24,7 @@ import {
|
||||
} from 'mastodon/components/scrollable_list/components';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { useSearchAccounts } from 'mastodon/hooks/useSearchAccounts';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import {
|
||||
addCollectionItem,
|
||||
getCollectionItemIds,
|
||||
@ -55,25 +61,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='Can’t 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 doesn’t 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
|
||||
@ -259,10 +330,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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -410,6 +410,10 @@
|
||||
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
|
||||
"collections.sensitive": "Sensitive",
|
||||
"collections.share_short": "Share",
|
||||
"collections.suggestions.can_not_add": "Can’t be added",
|
||||
"collections.suggestions.can_not_add_desc": "These accounts may have opted out of discovery, or they might be on a server that doesn’t support collections.",
|
||||
"collections.suggestions.must_follow": "Must follow first",
|
||||
"collections.suggestions.must_follow_desc": "These accounts review all follow requests. Followers can add them to collections.",
|
||||
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
||||
"collections.topic_special_chars_hint": "Special characters will be removed when saving",
|
||||
"collections.unlisted_collections_description": "These don’t appear on your profile to others. Anyone with the link can discover them.",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user