MM-64977: Fix channel switcher row overlap with long channel and team names (#36330)

* MM-64977: Fix channel switcher row overlap with long names

In the Find Channels modal, very long channel names overflowed
their row and visually overlapped the team name shown on the right
because the team label was absolutely positioned and the channel
column did not reserve horizontal space.

Restructure the SwitchChannelSuggestion row to use a real flex
layout: a primary column wrapper holds the channel name and inline
metadata with `flex: 1 1 auto; min-width: 0;` so the name truncates
with an ellipsis, and the team-name span becomes a flex sibling
with `flex: 0 0 auto; max-width: 40%;` so it remains visible. The
channel name is wrapped in WithTooltip whose disabled prop is
driven by a useLayoutEffect-based scrollWidth > clientWidth check,
so the full name is shown on hover only when truncation occurs.

Made-with: Cursor

* MM-64977: Show tooltip on truncated team name as well

Mirror the channel-name tooltip behavior on the team-name span in
the channel switcher row: track its truncation state via the same
useLayoutEffect + ref pattern, and wrap the team name in WithTooltip
whose disabled prop is driven by scrollWidth > clientWidth. Hovering
the team label now reveals the full team display name when (and only
when) it is actually truncated.

Extend existing tooltip tests to assert the team-name tooltip
disabled flag mirrors the truncation state in both branches; loosen
the layout test to permit the WithTooltip wrapper around the team
span while still asserting the team name does not live inside the
primary column.

Made-with: Cursor
This commit is contained in:
Maria A Nunez
2026-05-01 09:09:54 -04:00
committed by GitHub
parent 1ead9ff038
commit 01bb3dcf7a
3 changed files with 226 additions and 39 deletions
@@ -52,6 +52,25 @@ jest.mock('mattermost-redux/actions/channels', () => ({
})),
}));
jest.mock('components/with_tooltip', () => {
const ReactActual = jest.requireActual('react');
const Mock = jest.fn(({children, title, disabled}: {children: React.ReactNode; title: unknown; disabled?: boolean}) => {
return ReactActual.createElement(
'div',
{
'data-testid': 'with-tooltip',
'data-tooltip-title': typeof title === 'string' ? title : '',
'data-tooltip-disabled': String(Boolean(disabled)),
},
children,
);
});
return {
__esModule: true,
default: Mock,
};
});
describe('components/SwitchChannelProvider', () => {
const defaultState = {
entities: {
@@ -1472,4 +1491,122 @@ describe('SwitchChannelSuggestion', () => {
expect(suggestion).toHaveAccessibleName(channel3.display_name);
expect(suggestion).toHaveAccessibleDescription(`5 unread notifications ~${channel3.name} Public channel`);
});
describe('layout and tooltip behavior for long names', () => {
const longTeam1 = TestHelper.getTeamMock({
id: 'team1',
display_name: 'A Very Long Team Display Name That Will Likely Overflow Its Slot In The Switcher',
});
const longTeam2 = TestHelper.getTeamMock({
id: 'team2',
display_name: 'Another Long Team Two',
});
const longChannel = TestHelper.getChannelMock({
id: 'channel1',
team_id: 'team1',
name: 'super_long_channel_name',
display_name: 'Super Extremely Long Channel Display Name That Should Truncate With An Ellipsis',
});
afterEach(() => {
// reset prototype overrides between tests
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {configurable: true, value: 0});
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 0});
});
test('should render team name as a sibling of the primary column wrapper inside .suggestion-list__flex when on multiple teams', () => {
renderWithContext(
<ConnectedSwitchChannelSuggestion
{...baseProps}
term={longChannel.name}
item={{
channel: longChannel,
name: longChannel.name,
deactivated: false,
}}
/>,
getBaseState([longTeam1, longTeam2], [longChannel]),
);
const suggestion = document.getElementById(baseProps.id) as HTMLElement;
expect(suggestion).toBeInTheDocument();
// Both nodes (channel name and team name) are present
expect(screen.getByText(longChannel.display_name)).toBeInTheDocument();
expect(screen.getByText(longTeam1.display_name)).toBeInTheDocument();
// The flex row contains the primary column wrapper and the team name as siblings
const flexRow = suggestion.querySelector('.suggestion-list__flex') as HTMLElement;
expect(flexRow).not.toBeNull();
const primaryColumn = flexRow.querySelector(':scope > .suggestion-list__switch-channel-primary');
expect(primaryColumn).not.toBeNull();
const teamNameNode = flexRow.querySelector('.suggestion-list__team-name');
expect(teamNameNode).not.toBeNull();
expect(teamNameNode).toHaveTextContent(longTeam1.display_name);
// Team name must live outside the primary column so it remains a flex sibling that doesn't shrink with the channel name.
expect(primaryColumn!.contains(teamNameNode)).toBe(false);
// Channel name span should live inside the primary column with the truncation class
const channelNameNode = primaryColumn!.querySelector('.suggestion-list__channel-name-text');
expect(channelNameNode).not.toBeNull();
expect(channelNameNode).toHaveTextContent(longChannel.display_name);
});
test('should disable the channel-name tooltip when the channel name fits its container', () => {
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {configurable: true, value: 100});
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 100});
renderWithContext(
<ConnectedSwitchChannelSuggestion
{...baseProps}
term={longChannel.name}
item={{
channel: longChannel,
name: longChannel.name,
deactivated: false,
}}
/>,
getBaseState([longTeam1, longTeam2], [longChannel]),
);
const tooltips = screen.getAllByTestId('with-tooltip');
const channelNameTooltip = tooltips.find((node) => node.getAttribute('data-tooltip-title') === longChannel.display_name);
expect(channelNameTooltip).toBeDefined();
expect(channelNameTooltip).toHaveAttribute('data-tooltip-disabled', 'true');
const teamNameTooltip = tooltips.find((node) => node.getAttribute('data-tooltip-title') === longTeam1.display_name);
expect(teamNameTooltip).toBeDefined();
expect(teamNameTooltip).toHaveAttribute('data-tooltip-disabled', 'true');
});
test('should enable the channel-name tooltip when the channel name overflows its container', () => {
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {configurable: true, value: 500});
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 100});
renderWithContext(
<ConnectedSwitchChannelSuggestion
{...baseProps}
term={longChannel.name}
item={{
channel: longChannel,
name: longChannel.name,
deactivated: false,
}}
/>,
getBaseState([longTeam1, longTeam2], [longChannel]),
);
const tooltips = screen.getAllByTestId('with-tooltip');
const channelNameTooltip = tooltips.find((node) => node.getAttribute('data-tooltip-title') === longChannel.display_name);
expect(channelNameTooltip).toBeDefined();
expect(channelNameTooltip).toHaveAttribute('data-tooltip-disabled', 'false');
const teamNameTooltip = tooltips.find((node) => node.getAttribute('data-tooltip-title') === longTeam1.display_name);
expect(teamNameTooltip).toBeDefined();
expect(teamNameTooltip).toHaveAttribute('data-tooltip-disabled', 'false');
});
});
});
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import React, {useLayoutEffect, useRef, useState} from 'react';
import {defineMessage, useIntl} from 'react-intl';
import {connect, useSelector} from 'react-redux';
@@ -58,6 +58,7 @@ import ProfilePicture from 'components/profile_picture';
import SharedChannelIndicator from 'components/shared_channel_indicator';
import BotTag from 'components/widgets/tag/bot_tag';
import GuestTag from 'components/widgets/tag/guest_tag';
import WithTooltip from 'components/with_tooltip';
import {getArchiveIconClassName} from 'utils/channel_utils';
import {Constants, StoragePrefixes} from 'utils/constants';
@@ -145,6 +146,11 @@ export const SwitchChannelSuggestion = React.forwardRef<HTMLLIElement, Props>(({
const currentUserId = useSelector(getCurrentUserId);
const channelNameRef = useRef<HTMLSpanElement>(null);
const [isChannelNameTruncated, setIsChannelNameTruncated] = useState(false);
const teamNameRef = useRef<HTMLSpanElement>(null);
const [isTeamNameTruncated, setIsTeamNameTruncated] = useState(false);
const ids = usePrefixedIds(id, {
name: null,
channelType: null,
@@ -320,18 +326,32 @@ export const SwitchChannelSuggestion = React.forwardRef<HTMLLIElement, Props>(({
let teamName = null;
if (isRealChannel(channel) && channel.team_id && team) {
teamName = (
<span
id={ids.teamName}
className='ml-2 suggestion-list__team-name'
<WithTooltip
title={team.display_name}
disabled={!isTeamNameTruncated}
>
{team.display_name}
</span>
<span
id={ids.teamName}
ref={teamNameRef}
className='ml-2 suggestion-list__team-name'
>
{team.display_name}
</span>
</WithTooltip>
);
}
const showSlug = (isPartOfOnlyOneTeam || channel.type === Constants.DM_CHANNEL) && channel.type !== Constants.THREADS;
Reflect.deleteProperty(otherProps, 'dispatch');
useLayoutEffect(() => {
const channelEl = channelNameRef.current;
setIsChannelNameTruncated(Boolean(channelEl && channelEl.scrollWidth > channelEl.clientWidth));
const teamEl = teamNameRef.current;
setIsTeamNameTruncated(Boolean(teamEl && teamEl.scrollWidth > teamEl.clientWidth));
}, [name, description, showSlug, isPartOfOnlyOneTeam, team?.display_name, item.unread, channelIsArchived]);
return (
<SuggestionContainer
ref={ref}
@@ -344,26 +364,34 @@ export const SwitchChannelSuggestion = React.forwardRef<HTMLLIElement, Props>(({
>
{icon}
<div className='suggestion-list__ellipsis suggestion-list__flex'>
<span className='suggestion-list__main'>
<span
id={ids.name}
className={classNames({'suggestion-list__unread': item.unread && !channelIsArchived})}
>
{name}
</span>
{showSlug && description && (
<span
id={ids.description}
className='ml-2 suggestion-list__desc'
<div className='suggestion-list__switch-channel-primary'>
<span className='suggestion-list__main'>
<WithTooltip
title={name}
disabled={!isChannelNameTruncated}
>
{description}
</span>
)}
</span>
{customStatus}
{sharedIcon}
{tag && <span id={ids.tag}>{tag}</span>}
{badge}
<span
id={ids.name}
ref={channelNameRef}
className={classNames('suggestion-list__channel-name-text', {'suggestion-list__unread': item.unread && !channelIsArchived})}
>
{name}
</span>
</WithTooltip>
{showSlug && description && (
<span
id={ids.description}
className='ml-2 suggestion-list__desc'
>
{description}
</span>
)}
</span>
{customStatus}
{sharedIcon}
{tag && <span id={ids.tag}>{tag}</span>}
{badge}
</div>
{!isPartOfOnlyOneTeam && teamName}
</div>
</SuggestionContainer>
@@ -213,10 +213,6 @@
.modal & {
padding: 8px 3.2rem;
.suggestion-list__team-name {
right: 32px;
}
}
.suggestion-list__ellipsis {
@@ -248,11 +244,25 @@
.suggestion-list__flex {
display: flex;
width: 100%;
min-width: 0;
max-width: 100%;
align-items: center;
.suggestion-list__switch-channel-primary {
display: flex;
overflow: hidden;
min-width: 0;
flex: 1 1 auto;
align-items: center;
}
.suggestion-list__main {
display: flex;
overflow: hidden;
width: unset;
min-width: 0;
flex: 1 1 auto;
white-space: nowrap;
> span:first-child {
overflow: hidden;
@@ -260,6 +270,28 @@
}
}
.suggestion-list__channel-name-text {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.suggestion-list_unread-mentions {
flex: 0 0 auto;
margin-left: auto;
}
.suggestion-list__team-name {
position: static;
overflow: hidden;
max-width: 40%;
flex: 0 0 auto;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
position: unset;
display: flex;
@@ -358,16 +390,6 @@
}
}
.suggestion-list__team-name {
position: absolute;
right: 20px;
overflow: hidden;
max-width: 20%;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.Tag {
flex-shrink: 0;
}