mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-29 05:09:16 +00:00
💄(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:
committed by
aleb_the_flash
parent
4cb7412194
commit
d9bf6efa2a
+1
-1
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user