Merge pull request #25755 from overleaf/mj-ide-collaborators-look

[web] Align online user design to Figma

GitOrigin-RevId: 89e09056558d98a57d3c1e5a8409476530784b26
This commit is contained in:
Mathias Jakobsen
2025-05-21 09:49:04 +01:00
committed by Copybot
parent b1daf50765
commit d9e2c3aa15
6 changed files with 297 additions and 1 deletions

View File

@@ -1042,6 +1042,8 @@
"my_library": "",
"n_items": "",
"n_items_plural": "",
"n_more_collaborators": "",
"n_more_collaborators_plural": "",
"n_more_updates_above": "",
"n_more_updates_above_plural": "",
"n_more_updates_below": "",

View File

@@ -0,0 +1,133 @@
import { OnlineUser } from '@/features/ide-react/context/online-users-context'
import {
Dropdown,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
// Should be kept in sync with $max-user-circles-displayed CSS constant
const MAX_USER_CIRCLES_DISPLAYED = 5
// We don't want a +1 circle since we could just show the user instead
const MAX_USERS_WITH_OVERFLOW_VISIBLE = MAX_USER_CIRCLES_DISPLAYED - 1
export const OnlineUsersWidget = ({
onlineUsers,
goToUser,
}: {
onlineUsers: OnlineUser[]
goToUser: (user: OnlineUser) => void
}) => {
const hasOverflow = onlineUsers.length > MAX_USER_CIRCLES_DISPLAYED
const usersBeforeOverflow = useMemo(
() =>
hasOverflow
? onlineUsers.slice(0, MAX_USERS_WITH_OVERFLOW_VISIBLE)
: onlineUsers,
[onlineUsers, hasOverflow]
)
const usersInOverflow = useMemo(
() =>
hasOverflow ? onlineUsers.slice(MAX_USERS_WITH_OVERFLOW_VISIBLE) : [],
[onlineUsers, hasOverflow]
)
return (
<div className="online-users-row">
{usersBeforeOverflow.map((user, index) => (
<OnlineUserWidget
key={`${user.user_id}_${index}`}
user={user}
goToUser={goToUser}
id={`online-user-${user.user_id}_${index}`}
/>
))}
{hasOverflow && (
<OnlineUserOverflow goToUser={goToUser} users={usersInOverflow} />
)}
</div>
)
}
const OnlineUserWidget = ({
user,
goToUser,
id,
}: {
user: OnlineUser
goToUser: (user: OnlineUser) => void
id: string
}) => {
const onClick = useCallback(() => {
goToUser(user)
}, [goToUser, user])
return (
<OLTooltip
id={id}
description={user.name}
overlayProps={{
placement: 'bottom',
trigger: ['hover', 'focus'],
delay: 0,
}}
>
<button className="online-users-row-button" onClick={onClick}>
<OnlineUserCircle user={user} />
</button>
</OLTooltip>
)
}
const OnlineUserCircle = ({ user }: { user: OnlineUser }) => {
const backgroundColor = getBackgroundColorForUserId(user.user_id)
return (
<span className="online-user-circle" style={{ backgroundColor }}>
{user.name.charAt(0)}
</span>
)
}
const OnlineUserOverflow = ({
goToUser,
users,
}: {
goToUser: (user: OnlineUser) => void
users: OnlineUser[]
}) => {
const { t } = useTranslation()
return (
<Dropdown align="end">
<DropdownToggle className="online-users-row-button online-user-overflow-toggle">
<OLTooltip
id="connected-users"
description={t('n_more_collaborators', { count: users.length })}
overlayProps={{ placement: 'bottom' }}
>
<span className="online-user-circle">+{users.length}</span>
</OLTooltip>
</DropdownToggle>
<DropdownMenu className="online-user-overflow-dropdown">
<DropdownHeader aria-hidden="true">
{t('connected_users')}
</DropdownHeader>
{users.map((user, index) => (
<li role="none" key={`${user.user_id}_${index}`}>
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => goToUser(user)}
>
<OnlineUserCircle user={user} /> {user.name}
</DropdownItem>
</li>
))}
</DropdownMenu>
</Dropdown>
)
}

View File

@@ -1,10 +1,10 @@
import OnlineUsersWidget from '@/features/editor-navigation-toolbar/components/online-users-widget'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import {
OnlineUser,
useOnlineUsersContext,
} from '@/features/ide-react/context/online-users-context'
import { useCallback } from 'react'
import { OnlineUsersWidget } from '../online-users/online-users-widget'
export const OnlineUsers = () => {
const { openDoc } = useEditorManagerContext()

View File

@@ -0,0 +1,69 @@
import { Meta } from '@storybook/react'
import { OnlineUser } from '@/features/ide-react/context/online-users-context'
import OnlineUsersWidgetOld from '@/features/editor-navigation-toolbar/components/online-users-widget'
import { OnlineUsersWidget } from '@/features/ide-redesign/components/online-users/online-users-widget'
const NAMES = [
'Alice',
'Bob',
'Charlie',
'Dave',
'Erin',
'Frank',
'Grace',
'Heidi',
'Ivan',
'Judy',
'Mallory',
'Niaj',
'Olivia',
'Peggy',
'Rupert',
]
const generateUser = (_: any, index: number): OnlineUser => {
const name = NAMES[index % NAMES.length]
return {
user_id: `user_${'b'.repeat(index)}`,
name,
id: `user-${index}`,
email: `${name.toLowerCase()}@example.com`,
}
}
export const OnlineUsersRedesign = ({ users }: { users: number }) => {
const generatedUsers = Array.from({ length: users }, generateUser)
return (
<div
style={{
backgroundColor: 'var(--online-users-border-color)',
padding: '20px',
}}
>
<OnlineUsersWidget onlineUsers={generatedUsers} goToUser={() => {}} />
</div>
)
}
export const OnlineUsersOld = ({ users }: { users: number }) => {
const generatedUsers = Array.from({ length: users }, generateUser)
return (
<div
style={{
backgroundColor: 'var(--online-users-border-color)',
padding: '20px',
}}
>
<OnlineUsersWidgetOld onlineUsers={generatedUsers} goToUser={() => {}} />
</div>
)
}
const meta: Meta<typeof OnlineUsersRedesign> = {
title: 'Editor / Online Users Widget',
args: {
users: 6,
},
}
export default meta

View File

@@ -1,9 +1,15 @@
:root {
--toolbar-btn-color: var(--white);
--online-users-border-color: var(--bg-dark-primary);
--online-users-text-color: var(--content-primary);
--online-users-overflow-button-background-color: var(--bg-light-secondary);
}
@include theme('light') {
--toolbar-btn-color: var(--neutral-70);
--online-users-border-color: var(--bg-light-primary);
--online-users-text-color: var(--content-primary);
--online-users-overflow-button-background-color: var(--bg-light-secondary);
}
.online-users {
@@ -35,3 +41,87 @@
align-items: center;
}
}
.online-users-row {
// Keep in sync with the MAX_USER_CIRCLES_DISPLAYED js constant
$max-user-circles-displayed: 5;
--online-users-circle-size: 24px;
--online-users-border-size: 1px;
--online-users-overlap: 8px;
--online-users-circle-padding: var(--spacing-01);
.online-user-overflow-dropdown {
--online-users-border-color: transparent;
}
display: flex;
align-items: center;
color: var(--online-users-text-color);
.online-users-row-button {
padding: 0;
margin: 0;
background: none;
border: none;
color: var(--online-users-text-color);
min-width: var(--online-users-circle-size);
height: var(--online-users-circle-size);
display: block;
font-weight: var(--font-weight-regular);
&.dropdown-toggle::after {
display: none;
}
&:not(:last-child, .online-user-overflow-toggle) {
margin-right: calc(
var(--online-users-overlap) * -1 + var(--online-users-border-size)
);
}
@for $i from 1 through $max-user-circles-displayed {
&:nth-child(#{$i}):not(:hover):not(.online-user-overflow-toggle) {
z-index: $i;
}
}
&:hover {
z-index: $max-user-circles-displayed + 2;
}
}
.online-user-overflow-toggle {
&:not(:hover) {
z-index: $max-user-circles-displayed + 1;
}
&:hover,
&:active,
& {
background: none;
}
.online-user-circle {
background-color: var(--online-users-overflow-button-background-color);
color: var(--online-users-text-color);
}
}
.online-user-circle {
padding: var(--online-users-circle-padding);
border-radius: 50%;
min-width: var(--online-users-circle-size);
height: var(--online-users-circle-size);
line-height: calc(
var(--online-users-circle-size) - 2 * var(--online-users-border-size) - 2 *
var(--online-users-circle-padding)
);
font-size: var(--font-size-01);
text-align: center;
border: var(--online-users-border-size) solid
var(--online-users-border-color);
box-sizing: border-box;
display: inline-block;
}
}

View File

@@ -1365,6 +1365,8 @@
"my_library": "My Library",
"n_items": "__count__ item",
"n_items_plural": "__count__ items",
"n_more_collaborators": "__count__ more collaborator",
"n_more_collaborators_plural": "__count__ more collaborators",
"n_more_updates_above": "__count__ more update above",
"n_more_updates_above_plural": "__count__ more updates above",
"n_more_updates_below": "__count__ more update below",