Update design of account search dropdown in collection editor (#38739)
This commit is contained in:
parent
ea33d7fba6
commit
05a1c170c2
@ -25,7 +25,7 @@
|
|||||||
.popover {
|
.popover {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-height: max(200px, 30dvh);
|
max-height: min(320px, 50dvh);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
@ -63,13 +63,7 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
&[data-highlighted='true'] {
|
&[data-highlighted='true'] {
|
||||||
color: var(--color-text-on-brand-base);
|
background: var(--color-bg-overlay-highlight);
|
||||||
background: var(--color-bg-brand-base);
|
|
||||||
|
|
||||||
&[aria-disabled='true'] {
|
|
||||||
color: var(--color-text-on-disabled);
|
|
||||||
background: var(--color-bg-disabled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[aria-disabled='true'] {
|
&[aria-disabled='true'] {
|
||||||
|
|||||||
@ -82,11 +82,12 @@ interface ComboboxProps<
|
|||||||
* Customise the rendering of group titles.
|
* Customise the rendering of group titles.
|
||||||
* The `titleId` must be attached to the element that provides the
|
* The `titleId` must be attached to the element that provides the
|
||||||
* accessible name for the group.
|
* accessible name for the group.
|
||||||
|
* Return `null` to omit rendering the group title.
|
||||||
*/
|
*/
|
||||||
renderGroupTitle?: (
|
renderGroupTitle?: (
|
||||||
groupKey: GroupKey,
|
groupKey: GroupKey,
|
||||||
titleId: string,
|
titleId: string,
|
||||||
) => React.ReactElement | string;
|
) => React.ReactElement | null;
|
||||||
/**
|
/**
|
||||||
* The main selection handler, called when an option is selected or deselected.
|
* 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 hasGroups = !Array.isArray(items);
|
||||||
const flatItems = useMemo(
|
const flatItems = useMemo(
|
||||||
() => (hasGroups ? (Object.values(items).flat() as Item[]) : items),
|
() =>
|
||||||
|
hasGroups
|
||||||
|
? (Object.values(items)
|
||||||
|
.flat()
|
||||||
|
.filter((i) => !!i) as Item[])
|
||||||
|
: items,
|
||||||
[hasGroups, items],
|
[hasGroups, items],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -478,10 +484,11 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
|
|||||||
{(Object.keys(items) as GroupKey[]).map((groupKey) => {
|
{(Object.keys(items) as GroupKey[]).map((groupKey) => {
|
||||||
const groupItems = items[groupKey];
|
const groupItems = items[groupKey];
|
||||||
const groupTitleId = `${listId}-group-${groupKey}`;
|
const groupTitleId = `${listId}-group-${groupKey}`;
|
||||||
const groupTitle = renderGroupTitle?.(
|
const customGroupTitle = renderGroupTitle?.(
|
||||||
groupKey,
|
groupKey,
|
||||||
groupTitleId,
|
groupTitleId,
|
||||||
) ?? <span id={groupTitleId}>{groupKey}</span>;
|
);
|
||||||
|
const hasTitle = customGroupTitle !== null;
|
||||||
|
|
||||||
if (!groupItems?.length) return null;
|
if (!groupItems?.length) return null;
|
||||||
|
|
||||||
@ -489,11 +496,18 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
|
|||||||
<ul
|
<ul
|
||||||
key={groupKey}
|
key={groupKey}
|
||||||
role='group'
|
role='group'
|
||||||
aria-labelledby={groupTitleId}
|
aria-labelledby={hasTitle ? groupTitleId : undefined}
|
||||||
>
|
>
|
||||||
<li role='presentation' className={classes.groupLabel}>
|
{hasTitle && (
|
||||||
{groupTitle}
|
<li
|
||||||
</li>
|
role='presentation'
|
||||||
|
className={classes.groupLabel}
|
||||||
|
>
|
||||||
|
{customGroupTitle ?? (
|
||||||
|
<span id={groupTitleId}>{groupKey}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{renderItems(groupItems)}
|
{renderItems(groupItems)}
|
||||||
</ul>
|
</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.
|
* 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
|
* Choose the child of the wrapper component based on needed interactivity:
|
||||||
* wrapper component.
|
* `ListItemContent` for a non-interactive item, `ListItemButton` or `ListItemLink`
|
||||||
|
* for interactive items.
|
||||||
*/
|
*/
|
||||||
export const ListItemWrapper: React.FC<WrapperProps> = ({
|
export const ListItemWrapper: React.FC<WrapperProps> = ({
|
||||||
icon,
|
icon,
|
||||||
@ -33,10 +34,31 @@ export const ListItemWrapper: React.FC<WrapperProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface LinkProps extends React.ComponentPropsWithoutRef<typeof Link> {
|
interface WithSubtitle {
|
||||||
subtitle?: React.ReactNode;
|
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> = ({
|
export const ListItemLink: React.FC<LinkProps> = ({
|
||||||
subtitle,
|
subtitle,
|
||||||
children,
|
children,
|
||||||
@ -44,20 +66,16 @@ export const ListItemLink: React.FC<LinkProps> = ({
|
|||||||
...otherProps
|
...otherProps
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<ListItemContent subtitle={subtitle}>
|
||||||
<h3 className={classes.title}>
|
<Link className={classNames(className, 'focusable')} {...otherProps}>
|
||||||
<Link className={classNames(className, 'focusable')} {...otherProps}>
|
{children}
|
||||||
{children}
|
</Link>
|
||||||
</Link>
|
</ListItemContent>
|
||||||
</h3>
|
|
||||||
{subtitle && <div className={classes.subtitle}>{subtitle}</div>}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
|
interface ButtonProps
|
||||||
subtitle?: React.ReactNode;
|
extends React.ComponentPropsWithoutRef<'button'>, WithSubtitle {}
|
||||||
}
|
|
||||||
|
|
||||||
export const ListItemButton: React.FC<ButtonProps> = ({
|
export const ListItemButton: React.FC<ButtonProps> = ({
|
||||||
subtitle,
|
subtitle,
|
||||||
@ -66,17 +84,14 @@ export const ListItemButton: React.FC<ButtonProps> = ({
|
|||||||
...otherProps
|
...otherProps
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<ListItemContent subtitle={subtitle}>
|
||||||
<h3 className={classes.title}>
|
<button
|
||||||
<button
|
type='button'
|
||||||
type='button'
|
className={classNames(className, 'focusable')}
|
||||||
className={classNames(className, 'focusable')}
|
{...otherProps}
|
||||||
{...otherProps}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</button>
|
||||||
</button>
|
</ListItemContent>
|
||||||
</h3>
|
|
||||||
{subtitle && <div className={classes.subtitle}>{subtitle}</div>}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,18 +7,31 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
|
|||||||
|
|
||||||
import { Icon } from '../icon';
|
import { Icon } from '../icon';
|
||||||
|
|
||||||
import { ListItemWrapper, ListItemButton, ListItemLink } from './index';
|
import {
|
||||||
|
ListItemWrapper,
|
||||||
|
ListItemContent,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemLink,
|
||||||
|
} from './index';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/ListItem',
|
title: 'Components/ListItem',
|
||||||
component: ListItemWrapper,
|
component: ListItemWrapper,
|
||||||
subcomponents: { ListItemButton, ListItemLink },
|
subcomponents: { ListItemContent, ListItemButton, ListItemLink },
|
||||||
} satisfies Meta<typeof ListItemWrapper>;
|
} satisfies Meta<typeof ListItemWrapper>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof 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 = {
|
export const WithButton: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<ListItemWrapper
|
<ListItemWrapper
|
||||||
|
|||||||
@ -10,8 +10,13 @@ import { AccountListItem } from 'mastodon/components/account_list_item';
|
|||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { useAccountHandle } from 'mastodon/components/display_name/default';
|
||||||
import { EmptyState } from 'mastodon/components/empty_state';
|
import { EmptyState } from 'mastodon/components/empty_state';
|
||||||
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
|
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
|
||||||
|
import {
|
||||||
|
ListItemContent,
|
||||||
|
ListItemWrapper,
|
||||||
|
} from 'mastodon/components/list_item';
|
||||||
import {
|
import {
|
||||||
Article,
|
Article,
|
||||||
ItemList,
|
ItemList,
|
||||||
@ -19,6 +24,7 @@ import {
|
|||||||
} from 'mastodon/components/scrollable_list/components';
|
} from 'mastodon/components/scrollable_list/components';
|
||||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||||
import { useSearchAccounts } from 'mastodon/hooks/useSearchAccounts';
|
import { useSearchAccounts } from 'mastodon/hooks/useSearchAccounts';
|
||||||
|
import { domain } from 'mastodon/initial_state';
|
||||||
import {
|
import {
|
||||||
addCollectionItem,
|
addCollectionItem,
|
||||||
getCollectionItemIds,
|
getCollectionItemIds,
|
||||||
@ -55,25 +61,90 @@ const AddedAccountItem: React.FC<{
|
|||||||
return <AccountListItem accountId={accountId} renderButton={renderButton} />;
|
return <AccountListItem accountId={accountId} renderButton={renderButton} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SuggestedAccountItem: React.FC<{ account: ApiMutedAccountJSON }> = (
|
const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => {
|
||||||
props,
|
const account = useAccount(id);
|
||||||
) => {
|
const handle = useAccountHandle(account, domain);
|
||||||
const account = useAccount(props.account.id);
|
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ListItemWrapper
|
||||||
<Avatar account={account} />
|
className={classes.suggestion}
|
||||||
<DisplayName account={account} />
|
icon={<Avatar account={account} size={40} />}
|
||||||
</>
|
>
|
||||||
|
<ListItemContent subtitle={handle}>
|
||||||
|
<DisplayName account={account} variant='simple' />
|
||||||
|
</ListItemContent>
|
||||||
|
</ListItemWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAccountItem = (account: ApiMutedAccountJSON) => (
|
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;
|
const getItemId = (account: ApiMutedAccountJSON) => account.id;
|
||||||
|
|
||||||
// Disable accounts who can't be added to a collection
|
// Disable accounts who can't be added to a collection
|
||||||
@ -259,10 +330,11 @@ export const CollectionAccounts: React.FC<{
|
|||||||
onKeyDown={handleSearchKeyDown}
|
onKeyDown={handleSearchKeyDown}
|
||||||
disabled={hasMaxAccounts}
|
disabled={hasMaxAccounts}
|
||||||
isLoading={isLoadingSuggestions}
|
isLoading={isLoadingSuggestions}
|
||||||
items={suggestedAccounts}
|
items={groupSuggestions(suggestedAccounts)}
|
||||||
getItemId={getItemId}
|
getItemId={getItemId}
|
||||||
getIsItemDisabled={getIsItemDisabled}
|
getIsItemDisabled={getIsItemDisabled}
|
||||||
renderItem={renderAccountItem}
|
renderItem={renderAccountItem}
|
||||||
|
renderGroupTitle={renderGroupTitle}
|
||||||
onSelectItem={handleSelectItem}
|
onSelectItem={handleSelectItem}
|
||||||
status={
|
status={
|
||||||
hasMaxAccounts
|
hasMaxAccounts
|
||||||
|
|||||||
@ -276,18 +276,16 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
</FormStack>
|
</FormStack>
|
||||||
|
|
||||||
<div className={classes.stickyFooter}>
|
<div className={classes.stickyFooter}>
|
||||||
<div className={classes.actionWrapper}>
|
<Button type='submit'>
|
||||||
<Button type='submit'>
|
{id ? (
|
||||||
{id ? (
|
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||||
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
) : (
|
||||||
) : (
|
<FormattedMessage
|
||||||
<FormattedMessage
|
id='collections.create_collection'
|
||||||
id='collections.create_collection'
|
defaultMessage='Create collection'
|
||||||
defaultMessage='Create collection'
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -58,3 +58,19 @@
|
|||||||
.scrollableWrapper {
|
.scrollableWrapper {
|
||||||
margin-inline: -16px;
|
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.search_accounts_max_reached": "You have added the maximum number of accounts",
|
||||||
"collections.sensitive": "Sensitive",
|
"collections.sensitive": "Sensitive",
|
||||||
"collections.share_short": "Share",
|
"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_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.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.",
|
"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