[Glitch] Increase clickable area around collection items, refactor ListItem component

Port 2b93a2211fbf8bceb521b8f2bc4f5c768c8ad9ed to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-04-27 11:11:07 +02:00 committed by Claire
parent 57017329a4
commit 83ea9b94de
10 changed files with 240 additions and 174 deletions

View File

@ -1,6 +1,8 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { polymorphicForwardRef } from '@/types/polymorphic';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
interface WrapperProps extends Omit< interface WrapperProps extends Omit<
@ -8,7 +10,7 @@ interface WrapperProps extends Omit<
'title' 'title'
> { > {
icon?: React.ReactNode; icon?: React.ReactNode;
iconEnd?: React.ReactNode; sideContent?: React.ReactNode;
} }
/** /**
@ -20,7 +22,7 @@ interface WrapperProps extends Omit<
*/ */
export const ListItemWrapper: React.FC<WrapperProps> = ({ export const ListItemWrapper: React.FC<WrapperProps> = ({
icon, icon,
iconEnd, sideContent,
children, children,
className, className,
...otherProps ...otherProps
@ -29,69 +31,68 @@ export const ListItemWrapper: React.FC<WrapperProps> = ({
<div {...otherProps} className={classNames(classes.wrapper, className)}> <div {...otherProps} className={classNames(classes.wrapper, className)}>
{icon} {icon}
<div>{children}</div> <div>{children}</div>
{iconEnd && <span className={classes.iconEnd}>{iconEnd}</span>} {sideContent && (
<span className={classes.sideContent}>{sideContent}</span>
)}
</div> </div>
); );
}; };
interface WithSubtitle { interface ContentProps {
subtitle?: React.ReactNode; subtitle?: React.ReactNode;
subtitleId?: string;
} }
interface ContentProps export const ListItemContent = polymorphicForwardRef<'h3', ContentProps>(
extends React.ComponentPropsWithoutRef<'h3'>, WithSubtitle {} (
{ as: Component = 'h3', subtitle, subtitleId, children, ...otherProps },
export const ListItemContent: React.FC<ContentProps> = ({ ref,
subtitle, ) => {
children, return (
...otherProps <>
}) => { <Component className={classes.title} ref={ref} {...otherProps}>
return ( {children}
<> </Component>
<h3 className={classes.title} {...otherProps}> {subtitle && (
{children} <div className={classes.subtitle} id={subtitleId}>
</h3> {subtitle}
{subtitle && <div className={classes.subtitle}>{subtitle}</div>} </div>
</> )}
); </>
}; );
},
);
interface LinkProps interface LinkProps
extends React.ComponentPropsWithoutRef<typeof Link>, WithSubtitle {} extends React.ComponentPropsWithoutRef<typeof Link>, ContentProps {}
export const ListItemLink: React.FC<LinkProps> = ({ export const ListItemLink = polymorphicForwardRef<'h3', LinkProps>(
subtitle, ({ as, subtitle, children, className, ...otherProps }, ref) => {
children, return (
className, <ListItemContent ref={ref} as={as} subtitle={subtitle}>
...otherProps <Link className={classNames(className, 'focusable')} {...otherProps}>
}) => { {children}
return ( </Link>
<ListItemContent subtitle={subtitle}> </ListItemContent>
<Link className={classNames(className, 'focusable')} {...otherProps}> );
{children} },
</Link> );
</ListItemContent>
);
};
interface ButtonProps interface ButtonProps
extends React.ComponentPropsWithoutRef<'button'>, WithSubtitle {} extends React.ComponentPropsWithoutRef<'button'>, ContentProps {}
export const ListItemButton: React.FC<ButtonProps> = ({ export const ListItemButton = polymorphicForwardRef<'h3', ButtonProps>(
subtitle, ({ as, subtitle, children, className, ...otherProps }, ref) => {
children, return (
className, <ListItemContent as={as} ref={ref} subtitle={subtitle}>
...otherProps <button
}) => { type='button'
return ( className={classNames(className, 'focusable')}
<ListItemContent subtitle={subtitle}> {...otherProps}
<button >
type='button' {children}
className={classNames(className, 'focusable')} </button>
{...otherProps} </ListItemContent>
> );
{children} },
</button> );
</ListItemContent>
);
};

View File

@ -5,6 +5,8 @@ import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { AvatarById } from '../avatar';
import { Button } from '../button';
import { Icon } from '../icon'; import { Icon } from '../icon';
import { import {
@ -36,7 +38,7 @@ export const WithButton: Story = {
render: () => ( render: () => (
<ListItemWrapper <ListItemWrapper
icon={<Icon icon={VisibilityOffIcon} id='visibility' />} icon={<Icon icon={VisibilityOffIcon} id='visibility' />}
iconEnd={<Icon icon={KeyboardArrowDownIcon} id='down' />} sideContent={<Icon icon={KeyboardArrowDownIcon} id='down' />}
> >
<ListItemButton subtitle='Youve blocked or muted these users'> <ListItemButton subtitle='Youve blocked or muted these users'>
3 hidden accounts 3 hidden accounts
@ -49,9 +51,22 @@ export const WithLink: Story = {
render: () => ( render: () => (
<ListItemWrapper <ListItemWrapper
icon={<Icon icon={VisibilityIcon} id='visibility' />} icon={<Icon icon={VisibilityIcon} id='visibility' />}
iconEnd={<Icon icon={ChevronRightIcon} id='right' />} sideContent={<Icon icon={ChevronRightIcon} id='right' />}
> >
<ListItemLink to='/'>View more</ListItemLink> <ListItemLink to='/'>View more</ListItemLink>
</ListItemWrapper> </ListItemWrapper>
), ),
}; };
export const WithInteractiveSideContent: Story = {
render: () => (
<ListItemWrapper
icon={<AvatarById accountId='1' size={40} />}
sideContent={<Button compact>Follow</Button>}
>
<ListItemLink to='/' subtitle='@test@example.com'>
Test account
</ListItemLink>
</ListItemWrapper>
),
};

View File

@ -1,11 +1,15 @@
.wrapper { .wrapper {
--list-item-padding: 16px;
--list-item-padding-block: var(--list-item-padding);
--list-item-gap: 12px;
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
gap: 12px; gap: var(--list-item-gap);
padding: 16px; padding: var(--list-item-padding-block) var(--list-item-padding);
font-size: 15px; font-size: 15px;
color: var(--color-text-primary); color: var(--color-text-primary);
} }
@ -29,7 +33,7 @@
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: var(--clickable-area-spread, 0);
} }
} }
} }
@ -39,6 +43,32 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.iconEnd { .sideContent {
display: flex;
gap: var(--list-item-gap);
align-self: stretch;
align-items: center;
margin-inline-start: auto; margin-inline-start: auto;
&:has(button, a) {
// If the sideContent has interactive children, move it
// above the clickable area of the .title content
position: relative;
z-index: 1;
// Cover up the .title content's clickable area in the
// padding box around the sideContent
&::before {
content: '';
position: absolute;
inset-block: calc(-1 * var(--list-item-padding-block));
inset-inline: calc(-1 * var(--list-item-padding));
z-index: -1;
}
}
// Improve vertical alignment of slotted icons
& > .icon {
vertical-align: middle;
}
} }

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useId, useState } from 'react';
import { Article } from '@/flavours/glitch/components/scrollable_list/components'; import { Article } from '@/flavours/glitch/components/scrollable_list/components';
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
@ -42,6 +42,8 @@ export const TruncatedListItems = <TListItem,>({
toggleButton, toggleButton,
renderListItem, renderListItem,
}: TruncatedListProps<TListItem>) => { }: TruncatedListProps<TListItem>) => {
const toggleButtonId = useId();
const toggleButtonDescId = `${toggleButtonId}-desc`;
const [showTruncatedItems, setShowTruncatedItems] = useState(false); const [showTruncatedItems, setShowTruncatedItems] = useState(false);
const toggleTruncatedItems = useCallback(() => { const toggleTruncatedItems = useCallback(() => {
setShowTruncatedItems((prev) => !prev); setShowTruncatedItems((prev) => !prev);
@ -65,14 +67,19 @@ export const TruncatedListItems = <TListItem,>({
}); });
})} })}
{hasHiddenAccounts && ( {hasHiddenAccounts && (
<Article aria-posinset={initialListSize} aria-setsize={totalListLength}> <Article
aria-posinset={initialListSize}
aria-setsize={totalListLength}
aria-labelledby={toggleButtonId}
aria-describedby={toggleButtonDescId}
>
<ListItemWrapper <ListItemWrapper
icon={ icon={
toggleButton.icon && ( toggleButton.icon && (
<Icon id='toggle-icon' icon={toggleButton.icon} /> <Icon id='toggle-icon' icon={toggleButton.icon} />
) )
} }
iconEnd={ sideContent={
<Icon <Icon
id='open-status' id='open-status'
icon={ icon={
@ -84,6 +91,8 @@ export const TruncatedListItems = <TListItem,>({
} }
> >
<ListItemButton <ListItemButton
id={toggleButtonId}
subtitleId={toggleButtonDescId}
aria-expanded={showTruncatedItems} aria-expanded={showTruncatedItems}
onClick={toggleTruncatedItems} onClick={toggleTruncatedItems}
subtitle={toggleButton.subtitle} subtitle={toggleButton.subtitle}

View File

@ -1,8 +1,7 @@
.wrapper { .wrapper {
display: flex; --list-item-padding: 16px;
align-items: start; --list-item-padding-block: 12px;
padding: 12px 16px; --list-item-gap: 16px;
gap: 16px;
&:not(.wrapperWithoutBorder) { &:not(.wrapperWithoutBorder) {
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
@ -10,8 +9,10 @@
} }
.menuButton { .menuButton {
align-self: start;
padding: 4px; padding: 4px;
margin-top: -2px; margin-top: -4px;
margin-inline-end: -4px;
svg { svg {
width: 20px; width: 20px;

View File

@ -9,7 +9,10 @@ import { CollectionMenu } from 'flavours/glitch/features/collections/components/
import classes from './collection_list_item.module.scss'; import classes from './collection_list_item.module.scss';
interface CollectionListItemProps extends CollectionLockupProps { interface CollectionListItemProps extends Omit<
CollectionLockupProps,
'sideContent'
> {
withoutBorder?: boolean; withoutBorder?: boolean;
positionInList: number; positionInList: number;
listSize: number; listSize: number;
@ -20,6 +23,7 @@ export const CollectionListItem: React.FC<CollectionListItemProps> = ({
withoutBorder, withoutBorder,
positionInList, positionInList,
listSize, listSize,
className,
...otherProps ...otherProps
}) => { }) => {
const uniqueId = useId(); const uniqueId = useId();
@ -29,21 +33,26 @@ export const CollectionListItem: React.FC<CollectionListItemProps> = ({
return ( return (
<Article <Article
focusable focusable
className={classNames(
classes.wrapper,
withoutBorder && classes.wrapperWithoutBorder,
)}
aria-labelledby={linkId} aria-labelledby={linkId}
aria-describedby={infoId} aria-describedby={infoId}
aria-posinset={positionInList} aria-posinset={positionInList}
aria-setsize={listSize} aria-setsize={listSize}
> >
<CollectionLockup collection={collection} {...otherProps} /> <CollectionLockup
<CollectionMenu
context='list'
collection={collection} collection={collection}
className={classes.menuButton} className={classNames(
classes.wrapper,
withoutBorder && classes.wrapperWithoutBorder,
className,
)}
sideContent={
<CollectionMenu
context='list'
collection={collection}
className={classes.menuButton}
/>
}
{...otherProps}
/> />
</Article> </Article>
); );

View File

@ -1,11 +1,5 @@
.content { .wrapper {
position: relative; --list-item-padding: 0px;
flex-grow: 1;
display: flex;
align-items: center;
column-gap: 12px;
color: var(--color-text-primary);
line-height: 1.3;
} }
.avatarGrid { .avatarGrid {
@ -36,27 +30,3 @@
fill: var(--color-text-primary); fill: var(--color-text-primary);
background: var(--color-bg-warning-softest); background: var(--color-bg-warning-softest);
} }
.link {
display: block;
font-size: 15px;
font-weight: 500;
text-decoration: none;
color: var(--color-text-primary);
&:hover {
color: var(--color-text-brand);
}
&::after {
// Increase clickable area by extending link across parent
content: '';
position: absolute;
inset: 0;
}
}
.info {
font-size: 13px;
color: var(--color-text-secondary);
}

View File

@ -1,10 +1,11 @@
import { useId } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom';
import {
ListItemLink,
ListItemWrapper,
} from '@/flavours/glitch/components/list_item';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { AvatarById } from 'flavours/glitch/components/avatar'; import { AvatarById } from 'flavours/glitch/components/avatar';
@ -47,71 +48,78 @@ export interface CollectionLockupProps {
collection: ApiCollectionJSON; collection: ApiCollectionJSON;
withAuthorHandle?: boolean; withAuthorHandle?: boolean;
withTimestamp?: boolean; withTimestamp?: boolean;
sideContent?: React.ReactNode;
className?: string;
} }
export const CollectionLockup: React.FC<CollectionLockupProps> = ({ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
collection, collection,
withAuthorHandle = true, withAuthorHandle = true,
withTimestamp, withTimestamp,
sideContent,
className,
}) => { }) => {
const { id, name } = collection; const { id, name } = collection;
const uniqueId = useId();
const linkId = `${uniqueId}-link`;
const infoId = `${uniqueId}-info`;
const authorAccount = useAccount(collection.account_id); const authorAccount = useAccount(collection.account_id);
const authorHandle = useAccountHandle(authorAccount, domain); const authorHandle = useAccountHandle(authorAccount, domain);
return ( const collectionInfo = (
<div className={classes.content}> <ul>
<AvatarGrid {collection.sensitive && (
accountIds={collection.items.map((item) => item.account_id)} <li className='sr-only'>
sensitive={collection.sensitive}
/>
<div>
<h2 id={linkId}>
<Link to={getCollectionPath(id)} className={classes.link}>
{name}
</Link>
</h2>
<ul className={classes.info} id={infoId}>
{collection.sensitive && (
<li className='sr-only'>
<FormattedMessage
id='collections.sensitive'
defaultMessage='Sensitive'
/>
</li>
)}
{withAuthorHandle && authorAccount && (
<FormattedMessage
id='collections.by_account'
defaultMessage='by {account_handle}'
values={{
account_handle: authorHandle,
}}
tagName='li'
/>
)}
<FormattedMessage <FormattedMessage
id='collections.account_count' id='collections.sensitive'
defaultMessage='{count, plural, one {# account} other {# accounts}}' defaultMessage='Sensitive'
values={{ count: collection.item_count }}
tagName='li'
/> />
{withTimestamp && ( </li>
<FormattedMessage )}
id='collections.last_updated_at' {withAuthorHandle && authorAccount && (
defaultMessage='Last updated: {date}' <FormattedMessage
values={{ id='collections.by_account'
date: ( defaultMessage='by {account_handle}'
<RelativeTimestamp timestamp={collection.updated_at} long /> values={{
), account_handle: authorHandle,
}} }}
tagName='li' tagName='li'
/> />
)} )}
</ul> <FormattedMessage
</div> id='collections.account_count'
</div> defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
{withTimestamp && (
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: <RelativeTimestamp timestamp={collection.updated_at} long />,
}}
tagName='li'
/>
)}
</ul>
);
return (
<ListItemWrapper
className={classNames(classes.wrapper, className)}
icon={
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
}
sideContent={sideContent}
>
<ListItemLink
as='h2'
to={getCollectionPath(id)}
subtitle={collectionInfo}
>
{name}
</ListItemLink>
</ListItemWrapper>
); );
}; };

View File

@ -1,8 +1,10 @@
.wrapper { .wrapper {
display: flex; --list-item-padding: 12px;
align-items: start;
padding: 12px;
gap: 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
} }
.removeButton {
align-self: start;
}

View File

@ -1,5 +1,9 @@
import { useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import type { CollectionLockupProps } from 'flavours/glitch/features/collections/components/collection_lockup'; import type { CollectionLockupProps } from 'flavours/glitch/features/collections/components/collection_lockup';
import { CollectionLockup } from 'flavours/glitch/features/collections/components/collection_lockup'; import { CollectionLockup } from 'flavours/glitch/features/collections/components/collection_lockup';
@ -14,9 +18,26 @@ export const CollectionPreviewCard: React.FC<CollectionPreviewCardProps> = ({
onRemove, onRemove,
...otherProps ...otherProps
}) => { }) => {
const intl = useIntl();
const removeButton = onRemove && (
<IconButton
icon='remove'
iconComponent={CloseIcon}
onClick={onRemove}
title={intl.formatMessage({
id: 'tag.remove',
defaultMessage: 'Remove',
})}
className={classes.removeButton}
/>
);
return ( return (
<div className={classNames(classes.wrapper, 'collection-preview')}> <CollectionLockup
<CollectionLockup collection={collection} {...otherProps} /> collection={collection}
</div> className={classNames(classes.wrapper, 'collection-preview')}
sideContent={removeButton}
{...otherProps}
/>
); );
}; };