Răsfoiți Sursa

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

[web] Align online user design to Figma

GitOrigin-RevId: 89e09056558d98a57d3c1e5a8409476530784b26
Mathias Jakobsen 1 an în urmă
părinte
comite
8a1cdab27e

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -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": "",

+ 133 - 0
services/web/frontend/js/features/ide-redesign/components/online-users/online-users-widget.tsx

@@ -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>
+  )
+}

+ 1 - 1
services/web/frontend/js/features/ide-redesign/components/toolbar/online-users.tsx

@@ -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()

+ 69 - 0
services/web/frontend/stories/online-users.stories.tsx

@@ -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

+ 90 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss

@@ -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;
+  }
+}

+ 2 - 0
services/web/locales/en.json

@@ -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",