Skip to content

Commit 8bc5ceb

Browse files
committed
refactor(mentions): use ListItemLayout for MentionItem components
1 parent 739b573 commit 8bc5ceb

27 files changed

Lines changed: 665 additions & 86 deletions
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import clsx from 'clsx';
2+
import type { ComponentProps, ComponentType, HTMLAttributes, ReactNode } from 'react';
3+
import React from 'react';
4+
5+
export type ListItemLayoutRootElement = Extract<
6+
keyof React.JSX.IntrinsicElements,
7+
keyof HTMLElementTagNameMap
8+
>;
9+
10+
export type ListItemLayoutBaseProps = {
11+
ContentSlot?: ComponentType<ListItemLayoutContentProps>;
12+
contentClassName?: string;
13+
description?: ReactNode;
14+
descriptionClassName?: string;
15+
destructive?: boolean;
16+
LeadingIcon?: ComponentType;
17+
LeadingSlot?: ComponentType;
18+
selected?: boolean;
19+
subtitle?: ReactNode;
20+
subtitleClassName?: string;
21+
title: ReactNode;
22+
titleClassName?: string;
23+
TrailingIcon?: ComponentType;
24+
TrailingSlot?: ComponentType;
25+
};
26+
27+
export type ListItemLayoutProps<RootElement extends ListItemLayoutRootElement = 'div'> =
28+
ListItemLayoutBaseProps & {
29+
RootElement?: RootElement;
30+
rootProps?: Omit<ComponentProps<RootElement>, 'children'>;
31+
};
32+
33+
export const ListItemLayout = <RootElement extends ListItemLayoutRootElement = 'div'>({
34+
ContentSlot = ListItemLayoutContent,
35+
contentClassName,
36+
description,
37+
descriptionClassName,
38+
destructive,
39+
LeadingIcon,
40+
LeadingSlot,
41+
RootElement,
42+
rootProps,
43+
selected,
44+
subtitle,
45+
subtitleClassName,
46+
title,
47+
titleClassName,
48+
TrailingIcon,
49+
TrailingSlot,
50+
}: ListItemLayoutProps<RootElement>) => {
51+
const RootComponent = RootElement ?? 'div';
52+
const resolvedRootProps = {
53+
...(RootComponent === 'button' ? { type: 'button' } : undefined),
54+
...rootProps,
55+
className: clsx(
56+
'str-chat__list-item-layout',
57+
rootProps?.className,
58+
destructive && 'str-chat__list-item-layout--destructive',
59+
selected && 'str-chat__list-item-layout--selected',
60+
),
61+
} as HTMLAttributes<HTMLElement>;
62+
63+
// JSX cannot type-check a generic intrinsic element with generic root props here.
64+
// Call sites still get RootElement-specific rootProps; createElement keeps rendering simple internally.
65+
return React.createElement(
66+
RootComponent,
67+
resolvedRootProps,
68+
LeadingIcon && (
69+
<span className='str-chat__list-item-layout__leading-icon'>
70+
<LeadingIcon />
71+
</span>
72+
),
73+
LeadingSlot && <LeadingSlot />,
74+
<ContentSlot
75+
className={contentClassName}
76+
description={description}
77+
descriptionClassName={descriptionClassName}
78+
subtitle={subtitle}
79+
subtitleClassName={subtitleClassName}
80+
title={title}
81+
titleClassName={titleClassName}
82+
/>,
83+
TrailingIcon && (
84+
<span className='str-chat__list-item-layout__trailing-icon'>
85+
<TrailingIcon />
86+
</span>
87+
),
88+
TrailingSlot && <TrailingSlot />,
89+
);
90+
};
91+
92+
export type ListItemLayoutContentProps = Omit<ComponentProps<'div'>, 'title'> & {
93+
description?: ReactNode;
94+
descriptionClassName?: string;
95+
subtitle?: ReactNode;
96+
subtitleClassName?: string;
97+
title: ReactNode;
98+
titleClassName?: string;
99+
};
100+
101+
export const ListItemLayoutContent = ({
102+
className,
103+
description,
104+
descriptionClassName,
105+
subtitle,
106+
subtitleClassName,
107+
title,
108+
titleClassName,
109+
...props
110+
}: ListItemLayoutContentProps) => (
111+
<div
112+
{...props}
113+
className={clsx('str-chat__list-item-layout__content', className, {
114+
'str-chat__list-item-layout__content--withDescription': description,
115+
'str-chat__list-item-layout__content--withSubtitle': subtitle,
116+
'str-chat__list-item-layout__content--withTitle': title,
117+
})}
118+
>
119+
{title && (
120+
<div className={clsx('str-chat__list-item-layout__title', titleClassName)}>
121+
{title}
122+
</div>
123+
)}
124+
{subtitle && (
125+
<div className={clsx('str-chat__list-item-layout__subtitle', subtitleClassName)}>
126+
{subtitle}
127+
</div>
128+
)}
129+
{description && (
130+
<div
131+
className={clsx('str-chat__list-item-layout__description', descriptionClassName)}
132+
>
133+
{description}
134+
</div>
135+
)}
136+
</div>
137+
);
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
4+
import type { ListItemLayoutContentProps } from '../ListItemLayout';
5+
import { ListItemLayout } from '../ListItemLayout';
6+
7+
const Icon = () => <svg data-testid='icon' />;
8+
9+
const Slot = () => <span data-testid='slot' />;
10+
11+
describe('ListItemLayout', () => {
12+
it('renders the default content structure with forwarded classes', () => {
13+
const { container } = render(
14+
<ListItemLayout
15+
contentClassName='custom-content'
16+
description='Description'
17+
descriptionClassName='custom-description'
18+
selected
19+
subtitle='Subtitle'
20+
subtitleClassName='custom-subtitle'
21+
title='Title'
22+
titleClassName='custom-title'
23+
/>,
24+
);
25+
26+
const root = container.firstElementChild;
27+
const content = container.querySelector('.str-chat__list-item-layout__content');
28+
const title = container.querySelector('.str-chat__list-item-layout__title');
29+
const subtitle = container.querySelector('.str-chat__list-item-layout__subtitle');
30+
const description = container.querySelector(
31+
'.str-chat__list-item-layout__description',
32+
);
33+
34+
expect(root).toHaveClass('str-chat__list-item-layout');
35+
expect(root).toHaveClass('str-chat__list-item-layout--selected');
36+
expect(content).toHaveClass('custom-content');
37+
expect(content).toHaveClass('str-chat__list-item-layout__content--withTitle');
38+
expect(content).toHaveClass('str-chat__list-item-layout__content--withSubtitle');
39+
expect(content).toHaveClass('str-chat__list-item-layout__content--withDescription');
40+
expect(title).toHaveClass('custom-title');
41+
expect(title).toHaveTextContent('Title');
42+
expect(subtitle).toHaveClass('custom-subtitle');
43+
expect(subtitle).toHaveTextContent('Subtitle');
44+
expect(description).toHaveClass('custom-description');
45+
expect(description).toHaveTextContent('Description');
46+
});
47+
48+
it('defaults button roots to type button while allowing explicit overrides', () => {
49+
const { rerender } = render(
50+
<ListItemLayout RootElement='button' title='Button title' />,
51+
);
52+
53+
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
54+
55+
rerender(
56+
<ListItemLayout
57+
RootElement='button'
58+
rootProps={{ type: 'submit' }}
59+
title='Button title'
60+
/>,
61+
);
62+
63+
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
64+
});
65+
66+
it('renders leading and trailing icon and slot components', () => {
67+
render(
68+
<ListItemLayout
69+
LeadingIcon={Icon}
70+
LeadingSlot={Slot}
71+
title='Title'
72+
TrailingIcon={Icon}
73+
TrailingSlot={Slot}
74+
/>,
75+
);
76+
77+
expect(screen.getAllByTestId('icon')).toHaveLength(2);
78+
expect(screen.getAllByTestId('slot')).toHaveLength(2);
79+
});
80+
81+
it('allows overriding the content slot', () => {
82+
const ContentSlot = ({
83+
description,
84+
descriptionClassName,
85+
subtitle,
86+
subtitleClassName,
87+
title,
88+
titleClassName,
89+
}: ListItemLayoutContentProps) => (
90+
<div data-testid='custom-content'>
91+
<span className={titleClassName}>{title}</span>
92+
<span className={subtitleClassName}>{subtitle}</span>
93+
<span className={descriptionClassName}>{description}</span>
94+
</div>
95+
);
96+
97+
render(
98+
<ListItemLayout
99+
ContentSlot={ContentSlot}
100+
description='Description'
101+
descriptionClassName='custom-description'
102+
subtitle='Subtitle'
103+
subtitleClassName='custom-subtitle'
104+
title='Title'
105+
titleClassName='custom-title'
106+
/>,
107+
);
108+
109+
expect(screen.getByTestId('custom-content')).toHaveTextContent(
110+
'TitleSubtitleDescription',
111+
);
112+
expect(screen.getByText('Title')).toHaveClass('custom-title');
113+
expect(screen.getByText('Subtitle')).toHaveClass('custom-subtitle');
114+
expect(screen.getByText('Description')).toHaveClass('custom-description');
115+
});
116+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './ListItemLayout';
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
@use '../../../styling/utils';
2+
3+
.str-chat__list-item-layout {
4+
--list-item-padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm);
5+
display: flex;
6+
align-items: center;
7+
gap: var(--str-chat__spacing-sm);
8+
text-align: start;
9+
padding: var(--list-item-padding);
10+
width: 100%;
11+
min-width: 0;
12+
border-radius: var(--str-chat__radius-md);
13+
14+
&.str-chat__list-item-layout--selected {
15+
background-color: var(--str-chat__background-utility-selected);
16+
}
17+
18+
&.str-chat__list-item-layout--destructive {
19+
color: var(--str-chat__accent-error);
20+
21+
.str-chat__list-item-layout__title,
22+
.str-chat__list-item-layout__subtitle,
23+
.str-chat__list-item-layout__description {
24+
color: var(--str-chat__accent-error);
25+
}
26+
}
27+
28+
&:disabled {
29+
color: var(--str-chat__text-disabled);
30+
31+
.str-chat__list-item-layout__title,
32+
.str-chat__list-item-layout__subtitle,
33+
.str-chat__list-item-layout__description {
34+
color: var(--str-chat__text-disabled);
35+
}
36+
}
37+
38+
&:is(button) {
39+
@include utils.button-reset;
40+
padding: var(--list-item-padding);
41+
cursor: pointer;
42+
43+
&:hover:not(:disabled) {
44+
background: var(--str-chat__background-utility-hover);
45+
}
46+
47+
&:active:not(:disabled) {
48+
background-color: var(--str-chat__background-utility-pressed);
49+
}
50+
51+
&:focus:not(:disabled) {
52+
@include utils.focusable;
53+
}
54+
}
55+
56+
.str-chat__list-item-layout__content {
57+
flex: 1;
58+
display: grid;
59+
align-items: start;
60+
grid-template-areas:
61+
'title'
62+
'description';
63+
grid-template-columns: minmax(0, 1fr);
64+
justify-items: start;
65+
min-width: 0;
66+
}
67+
68+
.str-chat__list-item-layout__content--withSubtitle {
69+
grid-template-areas:
70+
'title description'
71+
'subtitle description';
72+
grid-template-columns: minmax(0, 1fr) auto;
73+
column-gap: var(--str-chat__spacing-sm);
74+
}
75+
76+
.str-chat__list-item-layout__leading-icon,
77+
.str-chat__list-item-layout__trailing-icon {
78+
display: flex;
79+
flex-shrink: 0;
80+
width: var(--str-chat__icon-size-sm);
81+
height: var(--str-chat__icon-size-sm);
82+
83+
svg {
84+
stroke: currentColor;
85+
width: 100%;
86+
height: 100%;
87+
}
88+
}
89+
90+
.str-chat__list-item-layout__description,
91+
.str-chat__list-item-layout__title {
92+
font: var(--str-chat__font-caption-default);
93+
}
94+
95+
.str-chat__list-item-layout__subtitle {
96+
font: var(--str-chat__font-metadata-default);
97+
}
98+
99+
.str-chat__list-item-layout__title {
100+
color: var(--str-chat__text-primary);
101+
grid-area: title;
102+
}
103+
104+
.str-chat__list-item-layout__subtitle,
105+
.str-chat__list-item-layout__description {
106+
color: var(--str-chat__text-tertiary);
107+
}
108+
109+
.str-chat__list-item-layout__subtitle {
110+
grid-area: subtitle;
111+
}
112+
113+
.str-chat__list-item-layout__description {
114+
grid-area: description;
115+
}
116+
117+
.str-chat__list-item-layout__subtitle,
118+
.str-chat__list-item-layout__description,
119+
.str-chat__list-item-layout__title {
120+
@include utils.ellipsis-text;
121+
}
122+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@use 'ListItemLayout';

0 commit comments

Comments
 (0)