💄(frontend) improve participant name rendering in the list

Rework how the participant name is displayed in the participant
list to show as much of the name as possible before truncating.

When the name has to be truncated, add a tooltip so users can hover
to see the full name.

Requested by users.
This commit is contained in:
lebaudantoine
2026-07-28 22:16:03 +02:00
committed by aleb_the_flash
parent 4cb7412194
commit d9bf6efa2a
6 changed files with 126 additions and 131 deletions
+1 -1
View File
@@ -25,6 +25,7 @@ and this project adheres to
- ⚡️(frontend) limit unnecessary re-renders #1510
- 📝(legal) update terms of service
- 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list
## Fixed
@@ -35,7 +36,6 @@ and this project adheres to
- 🐛(backend) allow any string as sub in the API serializer
- 🐛(frontend) fall back to user.full_name on request-entry
## [1.24.0] - 2026-07-21
### Added
@@ -0,0 +1,68 @@
import React, { useLayoutEffect, useRef, useState } from 'react'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
export const ParticipantName = React.memo(
({ displayedName, isLocal }: { displayedName: string; isLocal: boolean }) => {
const { t } = useTranslation('rooms')
const nameRef = useRef<HTMLParagraphElement>(null)
const [isTruncated, setIsTruncated] = useState(false)
useLayoutEffect(() => {
const el = nameRef.current
if (!el) return
const truncated = el.scrollWidth > el.clientWidth
setIsTruncated((prev) => (prev === truncated ? prev : truncated))
}, [displayedName])
return (
<Text
as="div"
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
minWidth: 0,
width: 'full',
maxWidth: 'full',
})}
>
<VisualOnlyTooltip
tooltip={displayedName}
disabled={!isTruncated}
className={css({ display: 'flex', minWidth: 0, flex: 1 })}
>
<p
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
minWidth: 0,
})}
ref={nameRef}
>
{displayedName}
</p>
</VisualOnlyTooltip>
{isLocal && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
flexShrink: 0,
})}
>
({t('participants.you')})
</span>
)}
</Text>
)
}
)
ParticipantName.displayName = 'ParticipantName'
@@ -26,6 +26,7 @@ import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = {
participant: Participant
@@ -111,59 +112,26 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
width: 'full',
})}
>
<HStack>
<div
className={css({
position: 'relative',
})}
>
<HStack flex="1" minW="0">
<div className={css({ position: 'relative', flexShrink: 0 })}>
<Avatar name={name} bgColor={getParticipantColor(participant)} />
<PinBadge participant={participant} />
<UnauthenticatedBadge participant={participant} />
</div>
<VStack gap={0} alignItems="start">
<Text
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
<VStack gap={0} alignItems="start" minW="0" flex="1">
<ParticipantName
displayedName={name}
isLocal={isLocal(participant)}
/>
<Text variant="xsNote">
{getParticipantIsRoomOwner(participant) && t('participants.host')}
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
{getParticipantIsRoomMember(participant) &&
t('participants.member')}
</Text>
{getParticipantIsRoomOwner(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.cohost')}</Text>
)}
{getParticipantIsRoomMember(participant) && (
<Text variant="xsNote">{t('participants.member')}</Text>
)}
</VStack>
</HStack>
<HStack>
<HStack flexShrink={0}>
<MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} />
</HStack>
@@ -1,7 +1,6 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
@@ -11,6 +10,7 @@ import { isLocal } from '@/utils/livekit'
import { RiHand } from '@remixicon/react'
import { Button } from '@/primitives'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
import { ParticipantName } from './ParticipantName'
const ActionButton = ({
participant,
@@ -42,9 +42,7 @@ type HandRaisedListItemProps = {
}
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
return (
<HStack
role="listitem"
@@ -56,42 +54,15 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
width: 'full',
})}
>
<HStack>
<HStack flex="1" minW="0" overflow="hidden">
<Avatar name={name} bgColor={getParticipantColor(participant)} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
<ParticipantName displayedName={name} isLocal={isLocal(participant)} />
</HStack>
<HStack flexShrink={0}>
<AdminOrOwnerOnly>
<ActionButton participant={participant} name={name} />
</AdminOrOwnerOnly>
</HStack>
<AdminOrOwnerOnly>
<ActionButton participant={participant} name={name} />
</AdminOrOwnerOnly>
</HStack>
)
}
@@ -1,10 +1,11 @@
import { Button, Text } from '@/primitives'
import { Button } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '../api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react'
import { ParticipantName } from './ParticipantName'
export const WaitingParticipantRow = ({
participant,
@@ -26,42 +27,11 @@ export const WaitingParticipantRow = ({
width: 'full',
})}
>
<HStack
className={css({
flex: '1',
minWidth: '0',
})}
>
<HStack flex="1" minW="0">
<Avatar name={participant.username} bgColor={participant.color} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
flex: '1',
minWidth: '0',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
})}
>
{participant.username}
</span>
</Text>
<ParticipantName displayedName={participant.username} isLocal={false} />
</HStack>
<HStack
gap="0.25rem"
className={css({
flexShrink: '0',
})}
>
<HStack gap="0.25rem" flexShrink={0}>
<Button
size="sm"
variant="tertiary"
@@ -16,24 +16,19 @@ export type VisualOnlyTooltipProps = {
tooltip: string
ariaLabel?: string
tooltipPosition?: 'top' | 'bottom'
disabled?: boolean
className?: string
}
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
type VisualOnlyTooltipInnerProps = Omit<VisualOnlyTooltipProps, 'disabled'>
const VisualOnlyTooltipInner = ({
children,
tooltip,
ariaLabel,
className,
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
}: VisualOnlyTooltipInnerProps) => {
const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null)
@@ -135,6 +130,7 @@ export const VisualOnlyTooltip = ({
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
className={className}
>
{wrappedChild}
</div>
@@ -183,3 +179,25 @@ export const VisualOnlyTooltip = ({
</>
)
}
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
children,
disabled,
...props
}: VisualOnlyTooltipProps) => {
if (disabled) {
return children
}
return <VisualOnlyTooltipInner {...props}>{children}</VisualOnlyTooltipInner>
}