Pārlūkot izejas kodu

Merge pull request #19370 from overleaf/rd-dashboard-left-sidebar

[web] Migrate the left menu on the project dashboard part #1

GitOrigin-RevId: e22685e521bd7e56e426940cb56331d86d20cada
Rebeka Dekany 2 gadi atpakaļ
vecāks
revīzija
024048b6fb

+ 4 - 8
services/web/frontend/js/features/project-list/components/add-affiliation.tsx

@@ -1,8 +1,8 @@
 import { useTranslation } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import { useProjectListContext } from '../context/project-list-context'
 import getMeta from '../../../utils/meta'
 import classNames from 'classnames'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 export function useAddAffiliation() {
   const { totalProjectsCount } = useProjectListContext()
@@ -26,18 +26,14 @@ function AddAffiliation({ className }: AddAffiliationProps) {
     return null
   }
 
-  const classes = classNames('text-centered', 'add-affiliation', className)
+  const classes = classNames('text-center', 'add-affiliation', className)
 
   return (
     <div className={classes}>
       <p>{t('are_you_affiliated_with_an_institution')}</p>
-      <Button
-        bsStyle={null}
-        className="btn-secondary-info btn-secondary"
-        href="/user/settings"
-      >
+      <OLButton variant="secondary" href="/user/settings">
         {t('add_affiliation')}
-      </Button>
+      </OLButton>
     </div>
   )
 }

+ 19 - 13
services/web/frontend/js/features/project-list/components/color-picker/color-picker.tsx

@@ -1,9 +1,9 @@
-import Icon from '../../../../shared/components/icon'
 import useSelectColor from '../../hooks/use-select-color'
 import { SketchPicker } from 'react-color'
 import { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import Tooltip from '../../../../shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import MaterialIcon from '@/shared/components/material-icon'
 
 const PRESET_COLORS: ReadonlyArray<{ color: string; name: string }> = [
   { color: '#A7B1C2', name: 'Grey' },
@@ -48,7 +48,7 @@ function ColorPickerItem({ color, name }: ColorPickerItemProps) {
         {t('select_color', { name })}
       </span>
       {!pickingCustomColor && color === selectedColor && (
-        <Icon type="check" className="color-picker-item-icon" />
+        <MaterialIcon type="check" className="color-picker-item-icon" />
       )}
     </div>
   )
@@ -95,19 +95,25 @@ function MoreButton() {
         tabIndex={0}
         onKeyDown={handleKeyDown}
       >
-        <Tooltip
+        <OLTooltip
+          key="tooltip-color-picker-plus"
           id="tooltip-color-picker-plus"
           description={t('choose_a_custom_color')}
-          overlayProps={{ delay: 0, placement: 'bottom' }}
+          overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
         >
-          {isCustomColorSelected ? (
-            <Icon type="check" className="color-picker-item-icon" />
-          ) : showCustomPicker ? (
-            <Icon type="chevron-down" className="color-picker-more-open" />
-          ) : (
-            <Icon type="plus" className="color-picker-more" />
-          )}
-        </Tooltip>
+          <div>
+            {isCustomColorSelected ? (
+              <MaterialIcon type="check" className="color-picker-item-icon" />
+            ) : showCustomPicker ? (
+              <MaterialIcon
+                type="expand_more"
+                className="color-picker-more-open"
+              />
+            ) : (
+              <MaterialIcon type="add" className="color-picker-more" />
+            )}
+          </div>
+        </OLTooltip>
       </div>
       {showCustomPicker && (
         <>

+ 51 - 38
services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx

@@ -1,8 +1,6 @@
 import { useCallback, useEffect, useState } from 'react'
-import { Button, ControlLabel, Form, FormGroup, Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Tag } from '../../../../../../app/src/Features/Tags/types'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
 import useAsync from '../../../../shared/hooks/use-async'
 import { useProjectListContext } from '../../context/project-list-context'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
@@ -11,6 +9,18 @@ import { createTag } from '../../util/api'
 import { MAX_TAG_LENGTH } from '../../util/tag'
 import { ColorPicker } from '../color-picker/color-picker'
 import { debugConsole } from '@/utils/debugging'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import OLForm from '@/features/ui/components/ol/ol-form'
 
 type CreateTagModalProps = {
   id: string
@@ -30,7 +40,7 @@ export default function CreateTagModal({
   const { tags } = useProjectListContext()
   const { selectedColor } = useSelectColor()
   const { t } = useTranslation()
-  const { isError, runAsync, status } = useAsync<Tag>()
+  const { isLoading, isError, runAsync, status } = useAsync<Tag>()
   const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
 
   const [tagName, setTagName] = useState<string>()
@@ -69,60 +79,63 @@ export default function CreateTagModal({
   }
 
   return (
-    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
-      <Modal.Header closeButton>
-        <Modal.Title>{t('create_new_tag')}</Modal.Title>
-      </Modal.Header>
+    <OLModal show animation onHide={onClose} id={id} backdrop="static">
+      <OLModalHeader closeButton>
+        <OLModalTitle>{t('create_new_tag')}</OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
-        <Form name="createTagForm" onSubmit={handleSubmit}>
-          <FormGroup>
-            <label htmlFor="new-tag-form-name">{t('new_tag_name')}</label>
-            <input
-              className="form-control"
-              id="new-tag-form-name"
+      <OLModalBody>
+        <OLForm id="create-tag-modal-form" onSubmit={handleSubmit}>
+          <OLFormGroup controlId="create-tag-modal-form">
+            <OLFormLabel>{t('new_tag_name')}</OLFormLabel>
+            <OLFormControl
               name="new-tag-form-name"
               onChange={e => setTagName(e.target.value)}
               ref={autoFocusedRef}
               required
               type="text"
             />
-          </FormGroup>
-          <FormGroup aria-hidden="true">
-            <ControlLabel>{t('tag_color')}</ControlLabel>:{' '}
+          </OLFormGroup>
+          <OLFormGroup aria-hidden="true">
+            <OLFormLabel>{t('tag_color')}</OLFormLabel>:{' '}
             <div>
               <ColorPicker disableCustomColor={disableCustomColor} />
             </div>
-          </FormGroup>
-        </Form>
-      </Modal.Body>
-
-      <Modal.Footer>
+          </OLFormGroup>
+        </OLForm>
         {validationError && (
-          <div className="modal-footer-left">
-            <span className="text-danger error">{validationError}</span>
-          </div>
+          <OLNotification type="error" content={validationError} />
         )}
         {isError && (
-          <div className="modal-footer-left">
-            <span className="text-danger error">
-              {t('generic_something_went_wrong')}
-            </span>
-          </div>
+          <OLNotification
+            type="error"
+            content={t('generic_something_went_wrong')}
+          />
         )}
-        <Button onClick={onClose} disabled={status === 'pending'}>
+      </OLModalBody>
+
+      <OLModalFooter>
+        <OLButton
+          variant="secondary"
+          onClick={onClose}
+          disabled={status === 'pending'}
+        >
           {t('cancel')}
-        </Button>
-        <Button
+        </OLButton>
+        <OLButton
           onClick={() => runCreateTag()}
-          bsStyle="primary"
+          variant="primary"
           disabled={
             status === 'pending' || !tagName?.length || !!validationError
           }
+          isLoading={isLoading}
+          bs3Props={{
+            loading: isLoading ? `${t('creating')}…` : t('create'),
+          }}
         >
-          {status === 'pending' ? <>{t('creating')} &hellip;</> : t('create')}
-        </Button>
-      </Modal.Footer>
-    </AccessibleModal>
+          {t('create')}
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }

+ 32 - 28
services/web/frontend/js/features/project-list/components/modals/delete-tag-modal.tsx

@@ -1,11 +1,17 @@
 import { useCallback } from 'react'
-import { Button, Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Tag } from '../../../../../../app/src/Features/Tags/types'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
 import useAsync from '../../../../shared/hooks/use-async'
 import { deleteTag } from '../../util/api'
 import { debugConsole } from '@/utils/debugging'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 type DeleteTagModalProps = {
   id: string
@@ -39,42 +45,40 @@ export default function DeleteTagModal({
   }
 
   return (
-    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
-      <Modal.Header closeButton>
-        <Modal.Title>{t('delete_tag')}</Modal.Title>
-      </Modal.Header>
+    <OLModal show animation onHide={onClose} id={id} backdrop="static">
+      <OLModalHeader closeButton>
+        <OLModalTitle>{t('delete_tag')}</OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
+      <OLModalBody>
         {t('about_to_delete_tag')}
         <ul>
           <li>{tag.name}</li>
         </ul>
-      </Modal.Body>
-
-      <Modal.Footer>
         {isError && (
-          <div className="modal-footer-left">
-            <span className="text-danger error">
-              {t('generic_something_went_wrong')}
-            </span>
-          </div>
+          <OLNotification
+            type="error"
+            content={t('generic_something_went_wrong')}
+          />
         )}
-        <Button
-          bsStyle={null}
-          className="btn-secondary"
-          onClick={onClose}
-          disabled={isLoading}
-        >
+      </OLModalBody>
+
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={onClose} disabled={isLoading}>
           {t('cancel')}
-        </Button>
-        <Button
+        </OLButton>
+        <OLButton
           onClick={() => runDeleteTag(tag._id)}
-          bsStyle="danger"
+          variant="danger"
           disabled={isLoading}
+          isLoading={isLoading}
+          bs3Props={{
+            loading: isLoading ? `${t('deleting')}…` : t('delete'),
+          }}
         >
-          {isLoading ? <>{t('deleting')} &hellip;</> : t('delete')}
-        </Button>
-      </Modal.Footer>
-    </AccessibleModal>
+          {t('delete')}
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }

+ 43 - 38
services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx

@@ -1,8 +1,6 @@
 import { useCallback, useEffect, useState } from 'react'
-import { Button, ControlLabel, Form, FormGroup, Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Tag } from '../../../../../../app/src/Features/Tags/types'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
 import useAsync from '../../../../shared/hooks/use-async'
 import { useProjectListContext } from '../../context/project-list-context'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
@@ -11,6 +9,17 @@ import { editTag } from '../../util/api'
 import { getTagColor, MAX_TAG_LENGTH } from '../../util/tag'
 import { ColorPicker } from '../color-picker/color-picker'
 import { debugConsole } from '@/utils/debugging'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLForm from '@/features/ui/components/ol/ol-form'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 type EditTagModalProps = {
   id: string
@@ -77,14 +86,14 @@ export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
   }
 
   return (
-    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
-      <Modal.Header closeButton>
-        <Modal.Title>{t('edit_tag')}</Modal.Title>
-      </Modal.Header>
+    <OLModal show animation onHide={onClose} id={id} backdrop="static">
+      <OLModalHeader closeButton>
+        <OLModalTitle>{t('edit_tag')}</OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
-        <Form name="renameTagForm" onSubmit={handleSubmit}>
-          <FormGroup>
+      <OLModalBody>
+        <OLForm onSubmit={handleSubmit}>
+          <OLFormGroup>
             <input
               ref={autoFocusedRef}
               className="form-control"
@@ -95,40 +104,32 @@ export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
               required
               onChange={e => setNewTagName(e.target.value)}
             />
-          </FormGroup>
-          <FormGroup aria-hidden="true">
-            <ControlLabel>{t('tag_color')}</ControlLabel>:{' '}
+          </OLFormGroup>
+          <OLFormGroup aria-hidden="true">
+            <OLFormLabel>{t('tag_color')}</OLFormLabel>:{' '}
             <div>
               <ColorPicker />
             </div>
-          </FormGroup>
-        </Form>
-      </Modal.Body>
-
-      <Modal.Footer>
+          </OLFormGroup>
+        </OLForm>
         {validationError && (
-          <div className="modal-footer-left">
-            <span className="text-danger error">{validationError}</span>
-          </div>
+          <OLNotification type="error" content={validationError} />
         )}
         {isError && (
-          <div className="modal-footer-left">
-            <span className="text-danger error">
-              {t('generic_something_went_wrong')}
-            </span>
-          </div>
+          <OLNotification
+            type="error"
+            content={t('generic_something_went_wrong')}
+          />
         )}
-        <Button
-          bsStyle={null}
-          className="btn-secondary"
-          onClick={onClose}
-          disabled={isLoading}
-        >
+      </OLModalBody>
+
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={onClose} disabled={isLoading}>
           {t('cancel')}
-        </Button>
-        <Button
+        </OLButton>
+        <OLButton
           onClick={() => runEditTag(tag._id)}
-          bsStyle="primary"
+          variant="primary"
           disabled={
             isLoading ||
             status === 'pending' ||
@@ -136,10 +137,14 @@ export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
             (newTagName === tag?.name && selectedColor === getTagColor(tag)) ||
             !!validationError
           }
+          isLoading={isLoading}
+          bs3Props={{
+            loading: isLoading ? `${t('saving')}…` : t('save'),
+          }}
         >
-          {isLoading ? <>{t('saving')} &hellip;</> : t('save')}
-        </Button>
-      </Modal.Footer>
-    </AccessibleModal>
+          {t('save')}
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }

+ 2 - 3
services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx

@@ -1,5 +1,4 @@
 import { useTranslation } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import {
   Filter,
   useProjectListContext,
@@ -19,9 +18,9 @@ export function SidebarFilter({ filter, text }: SidebarFilterProps) {
     <ProjectsFilterMenu filter={filter}>
       {isActive => (
         <li className={isActive ? 'active' : ''}>
-          <Button onClick={() => selectFilter(filter)} bsStyle={null}>
+          <button type="button" onClick={() => selectFilter(filter)}>
             {text}
-          </Button>
+          </button>
         </li>
       )}
     </ProjectsFilterMenu>

+ 75 - 44
services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx

@@ -1,7 +1,5 @@
 import { sortBy } from 'lodash'
-import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
-import Icon from '../../../../shared/components/icon'
 import MaterialIcon from '../../../../shared/components/material-icon'
 import {
   UNCATEGORIZED_KEY,
@@ -9,6 +7,13 @@ import {
 } from '../../context/project-list-context'
 import useTag from '../../hooks/use-tag'
 import { getTagColor } from '../../util/tag'
+import {
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 export default function TagsList() {
   const { t } = useTranslation()
@@ -35,14 +40,10 @@ export default function TagsList() {
         <h2>{t('organize_projects')}</h2>
       </li>
       <li className="tag">
-        <Button
-          className="tag-name"
-          onClick={openCreateTagModal}
-          bsStyle={null}
-        >
-          <Icon type="plus" />
+        <button type="button" className="tag-name" onClick={openCreateTagModal}>
+          <MaterialIcon type="add" className="tag-list-icon" />
           <span className="name">{t('new_tag')}</span>
-        </Button>
+        </button>
       </li>
       {sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => {
         return (
@@ -50,19 +51,19 @@ export default function TagsList() {
             className={`tag ${selectedTagId === tag._id ? 'active' : ''}`}
             key={tag._id}
           >
-            <Button
+            <button
+              type="button"
               className="tag-name"
               onClick={e =>
                 handleSelectTag(e as unknown as React.MouseEvent, tag._id)
               }
-              bsStyle={null}
             >
               <span
                 style={{
                   color: getTagColor(tag),
                 }}
               >
-                <MaterialIcon type="label" style={{ verticalAlign: 'sub' }} />
+                <MaterialIcon type="label" className="tag-list-icon" />
               </span>
               <span className="name">
                 {tag.name}{' '}
@@ -70,36 +71,66 @@ export default function TagsList() {
                   ({projectsPerTag[tag._id].length})
                 </span>
               </span>
-            </Button>
-            <span className="dropdown tag-menu">
-              <button
-                className="dropdown-toggle"
-                data-toggle="dropdown"
-                dropdown-toggle=""
-                aria-haspopup="true"
-                aria-expanded="false"
-              >
-                <span className="caret" />
-              </button>
-              <ul className="dropdown-menu dropdown-menu-right" role="menu">
-                <li>
-                  <Button
-                    onClick={e => handleEditTag(e, tag._id)}
-                    className="tag-action"
-                  >
-                    {t('edit')}
-                  </Button>
-                </li>
-                <li>
-                  <Button
-                    onClick={e => handleDeleteTag(e, tag._id)}
-                    className="tag-action"
+            </button>
+            <BootstrapVersionSwitcher
+              bs5={
+                <Dropdown align="end" className="tag-menu">
+                  <DropdownToggle id={`${tag._id}-dropdown-toggle`}>
+                    <span className="caret" />
+                  </DropdownToggle>
+                  <DropdownMenu className="sm">
+                    <DropdownItem
+                      as="li"
+                      className="tag-action"
+                      onClick={e => handleEditTag(e, tag._id)}
+                    >
+                      {t('edit')}
+                    </DropdownItem>
+                    <DropdownItem
+                      as="li"
+                      className="tag-action"
+                      onClick={e => handleDeleteTag(e, tag._id)}
+                    >
+                      {t('delete')}
+                    </DropdownItem>
+                  </DropdownMenu>
+                </Dropdown>
+              }
+              bs3={
+                <span className="dropdown tag-menu">
+                  <button
+                    type="button"
+                    className="dropdown-toggle"
+                    data-toggle="dropdown"
+                    dropdown-toggle=""
+                    aria-haspopup="true"
+                    aria-expanded="false"
                   >
-                    {t('delete')}
-                  </Button>
-                </li>
-              </ul>
-            </span>
+                    <span className="caret" />
+                  </button>
+                  <ul className="dropdown-menu dropdown-menu-right" role="menu">
+                    <li>
+                      <button
+                        type="button"
+                        onClick={e => handleEditTag(e, tag._id)}
+                        className="tag-action"
+                      >
+                        {t('edit')}
+                      </button>
+                    </li>
+                    <li>
+                      <button
+                        type="button"
+                        onClick={e => handleDeleteTag(e, tag._id)}
+                        className="tag-action"
+                      >
+                        {t('delete')}
+                      </button>
+                    </li>
+                  </ul>
+                </span>
+              }
+            />
           </li>
         )
       })}
@@ -109,16 +140,16 @@ export default function TagsList() {
             selectedTagId === UNCATEGORIZED_KEY ? 'active' : ''
           }`}
         >
-          <Button
+          <button
+            type="button"
             className="tag-name"
             onClick={() => selectTag(UNCATEGORIZED_KEY)}
-            bsStyle={null}
           >
             <span className="name">
               {t('uncategorized')}{' '}
               <span className="subdued">({untaggedProjectsCount})</span>
             </span>
-          </Button>
+          </button>
         </li>
       )}
       <CreateTagModal id="create-tag-modal" />

+ 1 - 0
services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts

@@ -12,6 +12,7 @@ export type DropdownProps = {
     | { xxl: 'start' | 'end' }
   as?: ElementType
   children: ReactNode
+  className?: string
   onSelect?: (eventKey: any, event: object) => any
   onToggle?: (show: boolean) => void
   show?: boolean

+ 9 - 6
services/web/frontend/stylesheets/app/project-list-react.less

@@ -941,26 +941,29 @@
 
     .color-picker-more {
       color: @gray-dark;
-      margin: 6px 0 0 7px;
+      margin: 3px;
+      font-weight: bold;
 
       @media (max-width: @screen-xs-max) {
-        margin: 8px 0 0 9px;
+        margin: 5px;
       }
     }
 
     .color-picker-more-open {
       color: @gray-dark;
-      margin: 5px 0 0 5px;
+      margin: 3px;
+      font-weight: bold;
 
       @media (max-width: @screen-xs-max) {
-        margin: 7px 0 0 7px;
+        margin: 5px;
       }
     }
   }
 
   .color-picker-item-icon {
-    margin: 6px 0 0 6px;
+    margin: 4px;
     color: @white;
+    font-weight: bold;
   }
 
   @media (max-width: @screen-xs-max) {
@@ -969,7 +972,7 @@
     margin: 24px 24px;
 
     .color-picker-item-icon {
-      margin: 8px 0 0 8px;
+      margin: 6px;
       color: @white;
     }
   }

+ 7 - 2
services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss

@@ -4,6 +4,7 @@
 
 .dropdown-header {
   @include body-xs;
+
   padding: var(--spacing-05) var(--spacing-06) var(--spacing-02)
     var(--spacing-04);
 }
@@ -12,16 +13,20 @@
   @include shadow-md;
 
   min-width: 240px;
+
+  &.sm {
+    min-width: 160px;
+  }
 }
 
 .dropdown-item {
   @include body-sm;
 
   --bs-dropdown-item-border-radius: var(--border-radius-base);
+
   display: grid;
   grid-auto-flow: column;
-  justify-content: start;
-  align-content: center;
+  place-content: center start;
   min-height: 44px; // a minimum height of 44px to be accessible for touch screens
   position: relative;
 

+ 282 - 7
services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss

@@ -1,11 +1,13 @@
 .project-list-empty-col {
   display: flex;
   height: 100%;
-  flex-direction: column;
+  flex-flow: column nowrap;
   flex-wrap: nowrap;
+
   .row:first-child {
     flex-grow: 1; /* fill vertical space so notifications are pushed to bottom */
   }
+
   .card-body {
     // h2 + .card-thin top padding
     padding-bottom: calc(var(--line-height-03) + var(--line-height-03) / 2);
@@ -47,8 +49,7 @@
 
       .project-list-sidebar-react {
         flex-grow: 1;
-        padding-left: var(--spacing-06);
-        padding-right: var(--spacing-06);
+        padding: var(--spacing-08) var(--spacing-06);
         -ms-overflow-style: -ms-autohiding-scrollbar;
         padding-top: var(--spacing-08);
         padding-bottom: var(--spacing-08);
@@ -61,6 +62,7 @@
         button {
           white-space: normal;
           word-wrap: anywhere;
+
           // prevents buttons from expanding sidebar width
         }
 
@@ -81,7 +83,8 @@
 
     .welcome-new-wrapper {
       .welcome-title {
-        @include heading-xl();
+        @include heading-xl;
+
         margin-top: var(--spacing-08);
       }
 
@@ -160,6 +163,196 @@
     padding: var(--spacing-08) var(--spacing-06);
   }
 
+  ul.project-list-filters {
+    margin: var(--spacing-05) calc(-1 * var(--spacing-06));
+
+    .subdued {
+      color: var(--content-disabled);
+    }
+
+    > li {
+      cursor: pointer;
+      position: relative;
+
+      > button {
+        width: 100%;
+        font-weight: normal;
+        text-align: left;
+        color: var(--white);
+        background-color: transparent;
+        border-radius: unset;
+        border: none;
+        border-bottom: solid 1px transparent;
+        padding: var(--spacing-03) var(--spacing-06);
+
+        &:hover {
+          background-color: var(--neutral-70);
+          text-decoration: none;
+        }
+
+        &:focus {
+          text-decoration: none;
+          outline: none;
+        }
+      }
+
+      &.separator {
+        padding: var(--spacing-03) var(--spacing-06);
+        cursor: auto;
+      }
+    }
+
+    > li.active {
+      border-radius: 0;
+
+      > button {
+        background-color: var(--neutral-90);
+        font-weight: 700;
+        color: var(--white);
+
+        .subdued {
+          color: var(--white);
+        }
+      }
+    }
+
+    h2 {
+      font-size: var(--font-size-02);
+      margin-bottom: var(--spacing-00);
+      color: var(--content-disabled);
+      text-transform: uppercase;
+      padding: var(--spacing-03) var(--spacing-00);
+    }
+
+    > li.tag {
+      &.active {
+        .tag-menu > button {
+          color: var(--white);
+          border-color: var(--white);
+
+          &:hover {
+            background-color: var(--neutral-90);
+          }
+        }
+      }
+
+      &.untagged {
+        button.tag-name {
+          span.name {
+            font-style: italic;
+            padding-left: 0;
+          }
+        }
+      }
+
+      &:hover {
+        &:not(.active) {
+          background-color: var(--neutral-70);
+        }
+
+        .tag-menu {
+          display: block;
+        }
+      }
+
+      &:not(.active) {
+        .tag-menu > a:hover {
+          background-color: var(--neutral-90);
+        }
+      }
+
+      button.tag-name {
+        position: relative;
+        padding: var(--spacing-03) var(--spacing-09) var(--spacing-03)
+          var(--spacing-06);
+        display: flex;
+        align-items: center;
+        word-wrap: anywhere;
+
+        .tag-list-icon {
+          vertical-align: sub;
+          font-weight: bold;
+        }
+
+        span.name {
+          padding-left: 0.5em;
+          line-height: 1.4;
+        }
+      }
+    }
+
+    .tag-menu {
+      button.dropdown-toggle {
+        border: 1px solid var(--white);
+        border-radius: var(--border-radius-base);
+        background-color: transparent;
+        color: var(--white);
+        display: block;
+        width: 16px;
+        height: 16px;
+        position: relative;
+        padding: var(--spacing-01) var(--spacing-03);
+
+        &::after {
+          position: absolute;
+          top: 4px;
+          left: -2px;
+        }
+      }
+
+      display: none;
+      width: auto;
+      position: absolute;
+      top: 50%;
+      margin-top: -8px; // Half the element height.
+      right: 4px;
+
+      &.open {
+        display: block;
+      }
+
+      button.tag-action {
+        border-radius: unset;
+        width: 100%;
+        background-color: transparent;
+        border-color: transparent;
+        color: var(--neutral-70);
+        text-align: left;
+        font-weight: normal;
+
+        &:hover {
+          color: var(--white);
+          background-color: var(--bg-accent-01);
+        }
+
+        &:active {
+          outline: none;
+        }
+      }
+    }
+  }
+}
+
+.add-affiliation-mobile-wrapper {
+  padding: var(--spacing-07) 0;
+}
+
+.add-affiliation {
+  .progress {
+    height: var(--spacing-05);
+    margin-bottom: var(--spacing-03);
+  }
+
+  p {
+    margin-bottom: var(--spacing-03);
+  }
+
+  &.is-mobile p {
+    @include body-xs;
+
+    white-space: normal;
+  }
+
   .project-dash-table {
     width: 100%;
     table-layout: fixed;
@@ -231,7 +424,8 @@
 
     .dash-cell-date-owner {
       font-size: $font-size-sm;
-      @include text-truncate();
+
+      @include text-truncate;
     }
 
     @include media-breakpoint-up(sm) {
@@ -417,7 +611,7 @@
   background-color: var(--bg-dark-tertiary);
   border-color: transparent;
   color: var(--neutral-20);
-  box-shadow: 2px 4px 6px rgba(0, 0, 0, 0.25);
+  box-shadow: 2px 4px 6px rgb(0 0 0 / 25%);
   border-radius: var(--border-radius-base);
 
   @include media-breakpoint-up(md) {
@@ -426,8 +620,8 @@
 
   button.close {
     @extend .text-white;
+
     padding: 0;
-    -webkit-appearance: none;
   }
 }
 
@@ -474,3 +668,84 @@ form.project-search {
 .project-search-clear-btn {
   @include reset-button;
 }
+
+.color-picker-item {
+  height: 28px;
+  width: 28px;
+  cursor: pointer;
+  position: relative;
+  outline: none;
+  border-radius: var(--border-radius-base);
+  margin: 0 var(--spacing-06) 0 0;
+  display: inline-block;
+  vertical-align: middle;
+
+  &:focus-visible {
+    box-shadow:
+      0 0 0 2px var(--white),
+      0 0 0 3px var(--blue-50),
+      0 0 0 5px var(--blue-30);
+  }
+
+  &.more-button {
+    border: 1px solid var(--neutral-70);
+
+    .color-picker-more {
+      color: var(--neutral-70);
+      margin: 3px; // it's centered, no matching spacing variable
+      font-weight: bold;
+
+      @include media-breakpoint-down(sm) {
+        margin: 5px; // it's centered, no matching spacing variable
+      }
+    }
+
+    .color-picker-more-open {
+      color: var(--neutral-70);
+      margin: 3px;
+      font-weight: bold;
+
+      @include media-breakpoint-down(sm) {
+        margin: 5px;
+      }
+    }
+  }
+
+  .color-picker-item-icon {
+    margin: 3px; // it's centered, no matching spacing variable
+    color: var(--white);
+    font-weight: bold;
+  }
+
+  @include media-breakpoint-down(sm) {
+    height: 32px;
+    width: 32px;
+    margin: var(--spacing-08);
+
+    .color-picker-item-icon {
+      margin: 5px; // it's centered, no matching spacing variable
+      color: var(--white);
+    }
+  }
+}
+
+.color-picker-more-wrapper {
+  position: relative;
+  display: inline-block;
+
+  .custom-picker {
+    position: absolute;
+    user-select: none;
+    z-index: 1;
+
+    @include media-breakpoint-down(sm) {
+      top: 56px;
+      left: 24px;
+    }
+  }
+
+  .popover-backdrop {
+    position: fixed;
+    inset: 0;
+  }
+}