mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-21 05:54:10 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user