Explorar o código

Update loading spinner status (#28177)

* Update test for the loading spinner component

* Create a story for the loading spinner component

* Move role and use CSS for spacing instead

* Add a classname prop

* Reuse LoadingSpinner

* Use OLSpinner instead of Spinner

* Use data-testid since status role was moved

* Wait for journals to load

* Use `isLoading` prop instead and fix the button's height

* Use `isLoading` prop instead

* Use LoadingSpinner instead and remove spacing

* Update test for the loading spinner component

* Use `isLoading` prop instead

* Add aria-describedby to layout button for processing state

* Replace `spinner` to `ol-spinner`

* Scope status

* Remove redundant `div.loading`

---------

Co-authored-by: Antoine Clausse <antoine.clausse@overleaf.com>
GitOrigin-RevId: 8f43b991f8f458b2abd5a4661913ac9b972d892a
Rebeka Dekany hai 10 meses
pai
achega
3b5ea89a1c
Modificáronse 27 ficheiros con 123 adicións e 212 borrados
  1. 5 2
      server-ce/test/helpers/project.ts
  2. 2 9
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx
  3. 7 21
      services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx
  4. 2 11
      services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx
  5. 7 43
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx
  6. 2 11
      services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx
  7. 2 7
      services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx
  8. 2 4
      services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-options.tsx
  9. 2 9
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.tsx
  10. 3 7
      services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx
  11. 3 7
      services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx
  12. 2 2
      services/web/frontend/js/features/settings/components/emails-section.tsx
  13. 2 10
      services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx
  14. 2 11
      services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx
  15. 2 11
      services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.tsx
  16. 2 2
      services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx
  17. 0 14
      services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx
  18. 2 2
      services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx
  19. 3 8
      services/web/frontend/js/shared/components/button/button.tsx
  20. 1 1
      services/web/frontend/js/shared/components/loading-spinner.tsx
  21. 9 2
      services/web/frontend/js/shared/components/ol/ol-spinner.tsx
  22. 3 12
      services/web/frontend/js/shared/components/select.tsx
  23. 52 0
      services/web/frontend/stories/loading-spinner.stories.tsx
  24. 1 0
      services/web/frontend/stylesheets/components/loading-spinner.scss
  25. 0 1
      services/web/frontend/stylesheets/pages/editor/toolbar.scss
  26. 2 2
      services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx
  27. 3 3
      services/web/test/frontend/components/shared/select.spec.tsx

+ 5 - 2
server-ce/test/helpers/project.ts

@@ -210,7 +210,7 @@ export function openFile(fileName: string, waitFor: string) {
     .click({ force: true })
 
   // wait until we've switched to the selected file
-  cy.findByText('Loading…').should('not.exist')
+  cy.findByRole('status').should('not.exist')
   cy.findByText(waitFor)
 }
 
@@ -230,7 +230,10 @@ export function createNewFile() {
     .click({ force: true })
 
   // wait until we've switched to the newly created empty file
-  cy.findByText('Loading…').should('not.exist')
+  cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
+    cy.findByRole('status').should('not.exist')
+    cy.get('.cm-line').should('have.length', 1)
+  })
   cy.get('.cm-line').should('have.length', 1)
 
   return fileName

+ 2 - 9
services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx

@@ -2,8 +2,8 @@ import OLFormGroup from '@/shared/components/ol/ol-form-group'
 import OLFormLabel from '@/shared/components/ol/ol-form-label'
 import OLFormSelect from '@/shared/components/ol/ol-form-select'
 import { ChangeEventHandler, useCallback, useEffect, useRef } from 'react'
-import { Spinner } from 'react-bootstrap'
 import { useEditorLeftMenuContext } from '@/features/editor-left-menu/components/editor-left-menu-context'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 type PossibleValue = string | number | boolean
 
@@ -82,14 +82,7 @@ export default function SettingsMenuSelect<T extends PossibleValue = string>({
     >
       <OLFormLabel>{label}</OLFormLabel>
       {loading ? (
-        <p className="mb-0">
-          <Spinner
-            animation="border"
-            aria-hidden="true"
-            size="sm"
-            role="status"
-          />
-        </p>
+        <OLSpinner size="sm" />
       ) : (
         <OLFormSelect
           size="sm"

+ 7 - 21
services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx

@@ -1,5 +1,4 @@
 import { memo, useCallback, forwardRef } from 'react'
-import { Spinner } from 'react-bootstrap'
 import {
   Dropdown,
   DropdownItem,
@@ -18,6 +17,7 @@ import useEventListener from '../../../shared/hooks/use-event-listener'
 import { DetachRole } from '@/shared/context/detach-context'
 import MaterialIcon from '@/shared/components/material-icon'
 import OLTooltip from '@/shared/components/ol/ol-tooltip'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 const isActiveDropdownItem = ({
   iconFor,
@@ -171,24 +171,18 @@ export const LayoutDropdownButtonUi = ({
   const { t } = useTranslation()
   return (
     <>
-      {processing && (
-        <div aria-live="assertive" className="visually-hidden">
-          {t('layout_processing')}
-        </div>
-      )}
+      <div aria-live="assertive" className="visually-hidden" id="layout-status">
+        {processing ? t('layout_processing') : ''}
+      </div>
       <Dropdown className="toolbar-item layout-dropdown" align="end">
         <DropdownToggle
+          aria-describedby={processing ? 'layout-status' : undefined}
           id="layout-dropdown-btn"
           className="btn-full-height"
           as={LayoutDropdownToggleButton}
         >
           {processing ? (
-            <Spinner
-              animation="border"
-              aria-hidden="true"
-              size="sm"
-              role="status"
-            />
+            <OLSpinner size="sm" />
           ) : (
             <MaterialIcon type="dock_to_right" className="align-middle" />
           )}
@@ -257,15 +251,7 @@ export const LayoutDropdownButtonUi = ({
                   detachIsLinked ? (
                     'check'
                   ) : (
-                    <span className="spinner-container">
-                      <Spinner
-                        animation="border"
-                        aria-hidden="true"
-                        size="sm"
-                        role="status"
-                      />
-                      <span className="visually-hidden">{t('loading')}</span>
-                    </span>
+                    <OLSpinner size="sm" />
                   )
                 ) : null
               }

+ 2 - 11
services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx

@@ -4,7 +4,6 @@ import { useIdeRedesignSwitcherContext } from '../ide-react/context/ide-redesign
 import { useTranslation } from 'react-i18next'
 import { canUseNewEditorViaPrimaryFeatureFlag } from '../ide-redesign/utils/new-editor-utils'
 import { useSwitchEnableNewEditorState } from '../ide-redesign/hooks/use-switch-enable-new-editor-state'
-import { Spinner } from 'react-bootstrap'
 
 const TryNewEditorButton = () => {
   const { t } = useTranslation()
@@ -27,17 +26,9 @@ const TryNewEditorButton = () => {
         onClick={onClick}
         size="sm"
         variant="secondary"
+        isLoading={loading}
       >
-        {loading ? (
-          <Spinner
-            animation="border"
-            aria-hidden="true"
-            size="sm"
-            role="status"
-          />
-        ) : (
-          t('try_the_new_editor')
-        )}
+        {t('try_the_new_editor')}
       </OLButton>
     </div>
   )

+ 7 - 43
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx

@@ -24,7 +24,7 @@ import OLFormGroup from '@/shared/components/ol/ol-form-group'
 import OLFormLabel from '@/shared/components/ol/ol-form-label'
 import OLForm from '@/shared/components/ol/ol-form'
 import OLFormSelect from '@/shared/components/ol/ol-form-select'
-import { Spinner } from 'react-bootstrap'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 export default function FileTreeImportFromProject() {
   const { t } = useTranslation()
@@ -209,20 +209,8 @@ function SelectProject({
 
   return (
     <OLFormGroup controlId="project-select">
-      <OLFormLabel>{t('select_a_project')}</OLFormLabel>
-
-      {loading && (
-        <span>
-          &nbsp;
-          <Spinner
-            animation="border"
-            aria-hidden="true"
-            size="sm"
-            role="status"
-          />
-        </span>
-      )}
-
+      <OLFormLabel className="me-1">{t('select_a_project')}</OLFormLabel>
+      {loading && <OLSpinner size="sm" />}
       <OLFormSelect
         disabled={!data}
         value={selectedProject ? selectedProject._id : ''}
@@ -275,20 +263,8 @@ function SelectProjectOutputFile({
       className="row-spaced-small"
       controlId="project-output-file-select"
     >
-      <OLFormLabel>{t('select_an_output_file')}</OLFormLabel>
-
-      {loading && (
-        <span>
-          &nbsp;
-          <Spinner
-            animation="border"
-            aria-hidden="true"
-            size="sm"
-            role="status"
-          />
-        </span>
-      )}
-
+      <OLFormLabel className="me-1">{t('select_an_output_file')}</OLFormLabel>
+      {loading && <OLSpinner size="sm" />}
       <OLFormSelect
         disabled={!data}
         value={selectedProjectOutputFile?.path || ''}
@@ -334,20 +310,8 @@ function SelectProjectEntity({
 
   return (
     <OLFormGroup className="row-spaced-small" controlId="project-entity-select">
-      <OLFormLabel>{t('select_a_file')}</OLFormLabel>
-
-      {loading && (
-        <span>
-          &nbsp;
-          <Spinner
-            animation="border"
-            aria-hidden="true"
-            size="sm"
-            role="status"
-          />
-        </span>
-      )}
-
+      <OLFormLabel className="me-1">{t('select_a_file')}</OLFormLabel>
+      {loading && <OLSpinner size="sm" />}
       <OLFormSelect
         disabled={!data}
         value={selectedProjectEntity?.path || ''}

+ 2 - 11
services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx

@@ -19,8 +19,8 @@ import { useGroupMembersContext } from '../../context/group-members-context'
 import getMeta from '@/utils/meta'
 import MaterialIcon from '@/shared/components/material-icon'
 import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
-import { Spinner } from 'react-bootstrap'
 import { sendMB } from '@/infrastructure/event-tracking'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 type resendInviteResponse = {
   success: boolean
@@ -322,16 +322,7 @@ function MenuItemButton({
         as="button"
         tabIndex={-1}
         onClick={onClick}
-        leadingIcon={
-          isLoading ? (
-            <Spinner
-              animation="border"
-              aria-hidden="true"
-              size="sm"
-              role="status"
-            />
-          ) : null
-        }
+        leadingIcon={isLoading ? <OLSpinner size="sm" /> : null}
         data-testid={dataTestId}
         variant={variant}
       >

+ 2 - 7
services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx

@@ -2,7 +2,7 @@ import OLFormSelect from '@/shared/components/ol/ol-form-select'
 import { ChangeEventHandler, useCallback } from 'react'
 import Setting from './setting'
 import classNames from 'classnames'
-import { Spinner } from 'react-bootstrap'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 type PossibleValue = string | number | boolean
 
@@ -64,12 +64,7 @@ export default function DropdownSetting<T extends PossibleValue = string>({
   return (
     <Setting controlId={id} label={label} description={description}>
       {loading ? (
-        <Spinner
-          animation="border"
-          aria-hidden="true"
-          size="sm"
-          role="status"
-        />
+        <OLSpinner size="sm" />
       ) : (
         <OLFormSelect
           id={id}

+ 2 - 4
services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-options.tsx

@@ -11,8 +11,8 @@ import React, { useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 import useEventListener from '@/shared/hooks/use-event-listener'
 import { DetachRole } from '@/shared/context/detach-context'
-import { Spinner } from 'react-bootstrap'
 import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
 
@@ -65,9 +65,7 @@ const LayoutDropdownItem = ({
 }) => {
   let trailingIcon: string | React.ReactNode | null = null
   if (processing) {
-    trailingIcon = (
-      <Spinner animation="border" aria-hidden="true" size="sm" role="status" />
-    )
+    trailingIcon = <OLSpinner size="sm" />
   } else if (active) {
     trailingIcon = 'check'
   }

+ 2 - 9
services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.tsx

@@ -9,7 +9,7 @@ import PdfHybridDownloadButton from './pdf-hybrid-download-button'
 import PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button'
 import PdfOrphanRefreshButton from './pdf-orphan-refresh-button'
 import { DetachedSynctexControl } from './detach-synctex-control'
-import { Spinner } from 'react-bootstrap'
+import LoadingSpinner from '@/shared/components/loading-spinner'
 
 const ORPHAN_UI_TIMEOUT_MS = 5000
 
@@ -91,14 +91,7 @@ function PdfPreviewHybridToolbarConnectingInner() {
   return (
     <>
       <div className="toolbar-pdf-orphan">
-        <Spinner
-          animation="border"
-          aria-hidden="true"
-          size="sm"
-          role="status"
-        />
-        &nbsp;
-        {t('tab_connecting')}…
+        <LoadingSpinner size="sm" loadingText={`${t('tab_connecting')}…`} />
       </div>
     </>
   )

+ 3 - 7
services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx

@@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next'
 import OLTooltip from '@/shared/components/ol/ol-tooltip'
 import OLButton from '@/shared/components/ol/ol-button'
 import MaterialIcon from '@/shared/components/material-icon'
-import { Spinner } from 'react-bootstrap'
 import { Placement } from 'react-bootstrap/types'
 import useSynctex from '../hooks/use-synctex'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 const GoToCodeButton = memo(function GoToCodeButton({
   syncToCode,
@@ -27,9 +27,7 @@ const GoToCodeButton = memo(function GoToCodeButton({
 
   let buttonIcon = null
   if (syncToCodeInFlight) {
-    buttonIcon = (
-      <Spinner animation="border" aria-hidden="true" size="sm" role="status" />
-    )
+    buttonIcon = <OLSpinner size="sm" />
   } else if (!isDetachLayout) {
     buttonIcon = (
       <MaterialIcon type="arrow_left_alt" className="synctex-control-icon" />
@@ -89,9 +87,7 @@ const GoToPdfButton = memo(function GoToPdfButton({
 
   let buttonIcon = null
   if (syncToPdfInFlight) {
-    buttonIcon = (
-      <Spinner animation="border" aria-hidden="true" size="sm" role="status" />
-    )
+    buttonIcon = <OLSpinner size="sm" />
   } else if (!isDetachLayout) {
     buttonIcon = (
       <MaterialIcon type="arrow_right_alt" className="synctex-control-icon" />

+ 3 - 7
services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx

@@ -1,5 +1,4 @@
 import { useTranslation } from 'react-i18next'
-import { Spinner } from 'react-bootstrap'
 import {
   Dropdown,
   DropdownItem,
@@ -18,6 +17,7 @@ import { Project } from '../../../../../../types/project/dashboard/api'
 import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
 import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
 import MaterialIcon from '@/shared/components/material-icon'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 type ActionDropdownProps = {
   project: Project
@@ -89,13 +89,9 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
                 }}
                 leadingIcon={
                   pendingCompile ? (
-                    <Spinner
-                      animation="border"
-                      aria-hidden="true"
-                      as="span"
-                      className="dropdown-item-leading-icon spinner"
+                    <OLSpinner
                       size="sm"
-                      role="status"
+                      className="dropdown-item-leading-icon spinner"
                     />
                   ) : (
                     'picture_as_pdf'

+ 2 - 2
services/web/frontend/js/features/settings/components/emails-section.tsx

@@ -9,7 +9,7 @@ import EmailsHeader from './emails/header'
 import EmailsRow from './emails/row'
 import AddEmail from './emails/add-email'
 import OLNotification from '@/shared/components/ol/ol-notification'
-import OLSpinner from '@/shared/components/ol/ol-spinner'
+import LoadingSpinner from '@/shared/components/loading-spinner'
 
 function EmailsSectionContent() {
   const { t } = useTranslation()
@@ -62,7 +62,7 @@ function EmailsSectionContent() {
         {isInitializing ? (
           <div className="affiliations-table-row-highlighted">
             <div className="affiliations-table-cell text-center">
-              <OLSpinner size="sm" /> {t('loading')}...
+              <LoadingSpinner size="sm" />
             </div>
           </div>
         ) : (

+ 2 - 10
services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx

@@ -7,9 +7,9 @@ import classnames from 'classnames'
 import MaterialIcon from '@/shared/components/material-icon'
 import Tag from '@/shared/components/tag'
 import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
-import { Spinner } from 'react-bootstrap'
 import { Contact } from '../utils/types'
 import OLFormLabel from '@/shared/components/ol/ol-form-label'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 export type ContactItem = {
   email: string
@@ -167,15 +167,7 @@ export default function SelectCollaborators({
       {/* eslint-disable-next-line jsx-a11y/label-has-for */}
       <OLFormLabel className="small" {...getLabelProps()}>
         {t('add_email_address')}
-        {loading && (
-          <Spinner
-            animation="border"
-            aria-hidden="true"
-            size="sm"
-            role="status"
-            className="ms-2"
-          />
-        )}
+        {loading && <OLSpinner size="sm" className="ms-2" />}
       </OLFormLabel>
 
       <div className="host">

+ 2 - 11
services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx

@@ -11,7 +11,7 @@ import OLModal, {
 } from '@/shared/components/ol/ol-modal'
 import OLNotification from '@/shared/components/ol/ol-notification'
 import OLButton from '@/shared/components/ol/ol-button'
-import { Spinner } from 'react-bootstrap'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 const ReadOnlyTokenLink = lazy(() =>
   import('./link-sharing').then(({ ReadOnlyTokenLink }) => ({
@@ -67,16 +67,7 @@ export default function ShareProjectModalContent({
       </OLModalBody>
 
       <OLModalFooter>
-        <div className="me-auto">
-          {inFlight && (
-            <Spinner
-              animation="border"
-              aria-hidden="true"
-              size="sm"
-              role="status"
-            />
-          )}
-        </div>
+        <div className="me-auto">{inFlight && <OLSpinner size="sm" />}</div>
 
         <ClickableElementEnhancer
           onClick={cancel}

+ 2 - 11
services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.tsx

@@ -11,8 +11,8 @@ import OLModal, {
 } from '@/shared/components/ol/ol-modal'
 import OLNotification from '@/shared/components/ol/ol-notification'
 import OLButton from '@/shared/components/ol/ol-button'
-import { Spinner } from 'react-bootstrap'
 import { ProjectMember } from '@/shared/context/types/project-metadata'
+import OLSpinner from '@/shared/components/ol/ol-spinner'
 
 export default function TransferOwnershipModal({
   member,
@@ -68,16 +68,7 @@ export default function TransferOwnershipModal({
         )}
       </OLModalBody>
       <OLModalFooter>
-        <div className="me-auto">
-          {inflight && (
-            <Spinner
-              animation="border"
-              aria-hidden="true"
-              size="sm"
-              role="status"
-            />
-          )}
-        </div>
+        <div className="me-auto">{inflight && <OLSpinner size="sm" />}</div>
         <OLButton variant="secondary" onClick={cancel} disabled={inflight}>
           {t('cancel')}
         </OLButton>

+ 2 - 2
services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx

@@ -1,6 +1,5 @@
 import { FC, useEffect, useMemo, useState } from 'react'
 import { WordCountData } from '@/features/word-count-modal/components/word-count-data'
-import { WordCountLoading } from '@/features/word-count-modal/components/word-count-loading'
 import { WordCountError } from '@/features/word-count-modal/components/word-count-error'
 import { useProjectContext } from '@/shared/context/project-context'
 import useAbortController from '@/shared/hooks/use-abort-controller'
@@ -14,6 +13,7 @@ import { isMainFile } from '@/features/pdf-preview/util/editor-files'
 import { countWordsInFile } from '@/features/word-count-modal/utils/count-words-in-file'
 import { createSegmenters } from '@/features/word-count-modal/utils/segmenters'
 import { WordCountsClient } from './word-counts-client'
+import LoadingSpinner from '@/shared/components/loading-spinner'
 
 export const WordCountClient: FC = () => {
   const [loading, setLoading] = useState(true)
@@ -107,7 +107,7 @@ export const WordCountClient: FC = () => {
 
   return (
     <>
-      {loading && !error && <WordCountLoading />}
+      {loading && !error && <LoadingSpinner />}
       {error && <WordCountError />}
       {data && <WordCountsClient data={data} />}
     </>

+ 0 - 14
services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx

@@ -1,14 +0,0 @@
-import { Spinner } from 'react-bootstrap'
-import { useTranslation } from 'react-i18next'
-
-export const WordCountLoading = () => {
-  const { t } = useTranslation()
-
-  return (
-    <div className="loading">
-      <Spinner animation="border" aria-hidden="true" size="sm" role="status" />
-      &nbsp;
-      {t('loading')}…
-    </div>
-  )
-}

+ 2 - 2
services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx

@@ -1,6 +1,5 @@
 import { FC, useEffect, useState } from 'react'
 import { ServerWordCountData } from '@/features/word-count-modal/components/word-count-data'
-import { WordCountLoading } from '@/features/word-count-modal/components/word-count-loading'
 import { WordCountError } from '@/features/word-count-modal/components/word-count-error'
 import { useProjectContext } from '@/shared/context/project-context'
 import { useLocalCompileContext } from '@/shared/context/local-compile-context'
@@ -8,6 +7,7 @@ import useAbortController from '@/shared/hooks/use-abort-controller'
 import { getJSON } from '@/infrastructure/fetch-json'
 import { debugConsole } from '@/utils/debugging'
 import { WordCounts } from '@/features/word-count-modal/components/word-counts'
+import LoadingSpinner from '@/shared/components/loading-spinner'
 
 export const WordCountServer: FC = () => {
   const { projectId } = useProjectContext()
@@ -40,7 +40,7 @@ export const WordCountServer: FC = () => {
 
   return (
     <>
-      {loading && !error && <WordCountLoading />}
+      {loading && !error && <LoadingSpinner />}
       {error && <WordCountError />}
       {data && <WordCounts data={data} />}
     </>

+ 3 - 8
services/web/frontend/js/shared/components/button/button.tsx

@@ -1,9 +1,10 @@
 import { forwardRef } from 'react'
-import { Button as BS5Button, Spinner } from 'react-bootstrap'
+import { Button as BS5Button } from 'react-bootstrap'
 import type { ButtonProps } from '@/shared/components/types/button-props'
 import classNames from 'classnames'
 import { useTranslation } from 'react-i18next'
 import MaterialIcon from '@/shared/components/material-icon'
+import OLSpinner from '../ol/ol-spinner'
 
 const Button = forwardRef<HTMLButtonElement, ButtonProps>(
   (
@@ -56,13 +57,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
       >
         {isLoading && (
           <span className="spinner-container">
-            <Spinner
-              animation="border"
-              aria-hidden="true"
-              as="span"
-              className={loadingSpinnerClassName}
-              role="status"
-            />
+            <OLSpinner size="sm" className={loadingSpinnerClassName} />
             <span className="visually-hidden">
               {loadingLabel ?? t('loading')}
             </span>

+ 1 - 1
services/web/frontend/js/shared/components/loading-spinner.tsx

@@ -42,6 +42,7 @@ function LoadingSpinner({
 
   return (
     <div
+      role="status"
       className={classNames(
         'loading',
         className,
@@ -49,7 +50,6 @@ function LoadingSpinner({
       )}
     >
       <OLSpinner size={size} />
-      &nbsp;
       {loadingText || `${t('loading')}…`}
     </div>
   )

+ 9 - 2
services/web/frontend/js/shared/components/ol/ol-spinner.tsx

@@ -2,13 +2,20 @@ import { Spinner } from 'react-bootstrap'
 
 export type OLSpinnerSize = 'sm' | 'lg'
 
-function OLSpinner({ size = 'sm' }: { size: OLSpinnerSize }) {
+function OLSpinner({
+  size = 'sm',
+  className,
+}: {
+  size?: OLSpinnerSize
+  className?: string
+}) {
   return (
     <Spinner
       size={size === 'sm' ? 'sm' : undefined}
       animation="border"
       aria-hidden="true"
-      role="status"
+      className={className}
+      data-testid="ol-spinner"
     />
   )
 }

+ 3 - 12
services/web/frontend/js/shared/components/select.tsx

@@ -9,10 +9,11 @@ import {
 import classNames from 'classnames'
 import { useSelect } from 'downshift'
 import { useTranslation } from 'react-i18next'
-import { Form, Spinner } from 'react-bootstrap'
+import { Form } from 'react-bootstrap'
 import FormControl from '@/shared/components/form/form-control'
 import MaterialIcon from '@/shared/components/material-icon'
 import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
+import OLSpinner from './ol/ol-spinner'
 
 export type SelectProps<T> = {
   // The items rendered as dropdown options.
@@ -152,17 +153,7 @@ export const Select = <T,>({
           {optionalLabel && (
             <span className="fw-normal">({t('optional')})</span>
           )}{' '}
-          {loading && (
-            <span data-testid="spinner">
-              <Spinner
-                animation="border"
-                aria-hidden="true"
-                as="span"
-                role="status"
-                size="sm"
-              />
-            </span>
-          )}
+          {loading && <OLSpinner size="sm" />}
         </Form.Label>
       ) : null}
       <FormControl

+ 52 - 0
services/web/frontend/stories/loading-spinner.stories.tsx

@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import LoadingSpinner, {
+  FullSizeLoadingSpinner,
+} from '@/shared/components/loading-spinner'
+
+type Story = StoryObj<typeof LoadingSpinner>
+
+export const Default: Story = {
+  args: {
+    loadingText: 'Loading content...',
+  },
+}
+
+export const WithDelay: Story = {
+  args: {
+    delay: 500,
+    loadingText: 'This will appear after a 500ms delay...',
+  },
+}
+
+export const FullSize: StoryObj<typeof FullSizeLoadingSpinner> = {
+  render: args => <FullSizeLoadingSpinner {...args} />,
+  args: {
+    loadingText: 'Loading entire section...',
+    size: 'sm',
+  },
+}
+
+const meta: Meta<typeof LoadingSpinner> = {
+  title: 'Shared / Components / Loading Spinner',
+  component: LoadingSpinner,
+  parameters: {
+    layout: 'centered',
+  },
+  argTypes: {
+    delay: {
+      control: 'select',
+      options: [0, 500],
+    },
+    size: {
+      control: 'radio',
+      options: ['lg', 'sm'],
+    },
+  },
+  args: {
+    size: 'sm',
+    delay: 0,
+  },
+  render: args => <LoadingSpinner {...args} />,
+}
+
+export default meta

+ 1 - 0
services/web/frontend/stylesheets/components/loading-spinner.scss

@@ -5,6 +5,7 @@
   .spinner-border {
     // Ensure the thickness of the spinner is independent of the font size of its container
     font-size: var(--font-size-03);
+    margin-right: var(--spacing-02);
   }
 
   // Adjust the small spinner to be 25% larger than Bootstrap's default in each dimension

+ 0 - 1
services/web/frontend/stylesheets/pages/editor/toolbar.scss

@@ -538,6 +538,5 @@
 
   max-height: 39px;
   font-size: var(--font-size-01);
-  line-height: var(--line-height-01);
   margin-right: var(--spacing-04);
 }

+ 2 - 2
services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx

@@ -434,7 +434,7 @@ describe('<PdfSynctexControls/>', function () {
         'not.be.disabled'
       )
 
-      cy.findByRole('status', { hidden: true }).should('not.exist')
+      cy.findByTestId('ol-spinner').should('not.exist')
 
       cy.wrap(null).then(() => {
         testDetachChannel.postMessage({
@@ -448,7 +448,7 @@ describe('<PdfSynctexControls/>', function () {
         'be.disabled'
       )
 
-      cy.findByRole('status', { hidden: true }).should('have.length', 1)
+      cy.findByTestId('ol-spinner').should('have.length', 1)
 
       cy.wrap(null).then(() => {
         testDetachChannel.postMessage({

+ 3 - 3
services/web/test/frontend/components/shared/select.spec.tsx

@@ -63,7 +63,7 @@ describe('<Select />', function () {
   describe('initial rendering', function () {
     it('renders default text', function () {
       render({ defaultText: 'Choose an item' })
-      cy.findByTestId('spinner').should('not.exist')
+      cy.findByTestId('ol-spinner').should('not.exist')
       cy.findByRole('combobox').should('have.value', 'Choose an item')
     })
 
@@ -102,7 +102,7 @@ describe('<Select />', function () {
         label: 'test label',
         loading: true,
       })
-      cy.findByTestId('spinner')
+      cy.findByTestId('ol-spinner')
     })
 
     it('does not render a spinner while loading if there is no label', function () {
@@ -110,7 +110,7 @@ describe('<Select />', function () {
         defaultText: 'Choose an item',
         loading: true,
       })
-      cy.findByTestId('spinner').should('not.exist')
+      cy.findByTestId('ol-spinner').should('not.exist')
     })
   })