Pārlūkot izejas kodu

[web] Spelling correction Dropdown to BS5 (#21493)

* Split `SpellingSuggestions` into a BS3 and BS5 version

* Migrate `B5SpellingSuggestions` to BS5

* Add `.dropdown-menu.dropdown-menu-unpositioned` styles

This makes the dropdown position itself without overflows

* Make spelling tooltip background transparent

* Migrate Cog icon to BS5

* Use `PolymorphicComponent`

Co-authored-by: Ilkin Ismailov <ilkin.ismailov@overleaf.com>

* Fix formatting

---------

Co-authored-by: Ilkin Ismailov <ilkin.ismailov@overleaf.com>
GitOrigin-RevId: aaa6c589637971031d13ac099f935fe2052e6989
Antoine Clausse 1 gadu atpakaļ
vecāks
revīzija
053831b48c

+ 1 - 0
services/web/frontend/js/features/source-editor/extensions/spelling/index.ts

@@ -61,6 +61,7 @@ const spellingTheme = EditorView.baseTheme({
   },
   '.cm-tooltip.ol-cm-spelling-context-menu-tooltip': {
     borderWidth: '0',
+    background: 'transparent',
   },
 })
 

+ 28 - 22
services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions-feedback.tsx

@@ -4,10 +4,12 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 import classnames from 'classnames'
 import MaterialIcon from '@/shared/components/material-icon'
+import { Dropdown } from 'react-bootstrap-5'
+import { bsVersion, isBootstrap5 } from '@/features/utils/bootstrap-5'
+import PolymorphicComponent from '@/shared/components/polymorphic-component'
 
 const SpellingSuggestionsFeedback: FC = () => {
   const { t } = useTranslation()
-
   return (
     <OLTooltip
       id="spell-check-client-tooltip"
@@ -21,27 +23,31 @@ const SpellingSuggestionsFeedback: FC = () => {
       tooltipProps={{ className: 'split-test-badge-tooltip' }}
       overlayProps={{ placement: 'bottom', delay: 100 }}
     >
-      <a
-        href="https://docs.google.com/forms/d/e/1FAIpQLSdD1wa5SiCZ7x_UF6e8vywTN82kSm6ou2rTKz-XBiEjNilOXQ/viewform"
-        target="_blank"
-        rel="noopener noreferrer"
-      >
-        <BootstrapVersionSwitcher
-          bs3={
-            <span
-              className={classnames('badge', 'info-badge')}
-              style={{ width: 14, height: 14 }}
-            />
-          }
-          bs5={
-            <MaterialIcon
-              type="info"
-              className={classnames('align-middle', 'info-badge')}
-            />
-          }
-        />
-        <span className="mx-2">{t('give_feedback')}</span>
-      </a>
+      <span>
+        <PolymorphicComponent
+          as={isBootstrap5() ? Dropdown.Item : 'a'}
+          href="https://docs.google.com/forms/d/e/1FAIpQLSdD1wa5SiCZ7x_UF6e8vywTN82kSm6ou2rTKz-XBiEjNilOXQ/viewform"
+          target="_blank"
+          rel="noopener noreferrer"
+          className={bsVersion({ bs3: 'dropdown-menu-button' })}
+        >
+          <BootstrapVersionSwitcher
+            bs3={
+              <span
+                className={classnames('badge', 'info-badge')}
+                style={{ width: 14, height: 14 }}
+              />
+            }
+            bs5={
+              <MaterialIcon
+                type="info"
+                className={classnames('align-middle', 'info-badge')}
+              />
+            }
+          />
+          <span className="mx-2">{t('give_feedback')}</span>
+        </PolymorphicComponent>
+      </span>
     </OLTooltip>
   )
 }

+ 21 - 6
services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions-language.tsx

@@ -2,6 +2,11 @@ import { memo, useCallback } from 'react'
 import Icon from '@/shared/components/icon'
 import { useTranslation } from 'react-i18next'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import { bsVersion, isBootstrap5 } from '@/features/utils/bootstrap-5'
+import { Dropdown } from 'react-bootstrap-5'
+import PolymorphicComponent from '@/shared/components/polymorphic-component'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 export const SpellingSuggestionsLanguage = memo<{
   language: { name: string }
@@ -27,12 +32,22 @@ export const SpellingSuggestionsLanguage = memo<{
       description={t('change_language')}
       overlayProps={{ placement: 'right', delay: 100 }}
     >
-      <button
-        className="btn-link text-left dropdown-menu-button"
-        onClick={handleClick}
-      >
-        <Icon type="cog" /> <span className="mx-1">{language.name}</span>
-      </button>
+      <span>
+        <PolymorphicComponent
+          as={isBootstrap5() ? Dropdown.Item : 'a'}
+          className={bsVersion({
+            bs3: 'btn-link text-left dropdown-menu-button',
+            bs5: 'd-flex gap-2 align-items-center',
+          })}
+          onClick={handleClick}
+        >
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="cog" />}
+            bs5={<MaterialIcon type="settings" />}
+          />
+          <span className={bsVersion({ bs3: 'ms-1' })}>{language.name}</span>
+        </PolymorphicComponent>
+      </span>
     </OLTooltip>
   )
 })

+ 141 - 11
services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx

@@ -15,6 +15,9 @@ import SpellingSuggestionsFeedback from './spelling-suggestions-feedback'
 import { SpellingSuggestionsLanguage } from './spelling-suggestions-language'
 import { captureException } from '@/infrastructure/error-reporter'
 import { debugConsole } from '@/utils/debugging'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { SpellCheckLanguage } from '../../../../../../types/project-settings'
+import { Dropdown } from 'react-bootstrap-5'
 
 const ITEMS_TO_SHOW = 8
 
@@ -22,14 +25,16 @@ const ITEMS_TO_SHOW = 8
 const wrapArrayIndex = (index: number, length: number) =>
   ((index % length) + length) % length
 
-export const SpellingSuggestions: FC<{
+type SpellingSuggestionsProps = {
   word: Word
   spellCheckLanguage?: string
   spellChecker?: SpellChecker | null
   handleClose: () => void
   handleLearnWord: () => void
   handleCorrectWord: (text: string) => void
-}> = ({
+}
+
+export const SpellingSuggestions: FC<SpellingSuggestionsProps> = ({
   word,
   spellCheckLanguage,
   spellChecker,
@@ -37,8 +42,6 @@ export const SpellingSuggestions: FC<{
   handleLearnWord,
   handleCorrectWord,
 }) => {
-  const { t } = useTranslation()
-
   const [suggestions, setSuggestions] = useState(() =>
     Array.isArray(word.suggestions)
       ? word.suggestions.slice(0, ITEMS_TO_SHOW)
@@ -47,10 +50,6 @@ export const SpellingSuggestions: FC<{
 
   const [waiting, setWaiting] = useState(!word.suggestions)
 
-  const [selectedIndex, setSelectedIndex] = useState(0)
-
-  const itemsLength = suggestions.length + 1
-
   useEffect(() => {
     if (!word.suggestions) {
       spellChecker
@@ -85,6 +84,46 @@ export const SpellingSuggestions: FC<{
     return null
   }
 
+  const innerProps = {
+    suggestions,
+    waiting,
+    handleClose,
+    handleCorrectWord,
+    handleLearnWord,
+    language,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<B3SpellingSuggestions {...innerProps} />}
+      bs5={<B5SpellingSuggestions {...innerProps} />}
+    />
+  )
+}
+
+type SpellingSuggestionsInnerProps = {
+  suggestions: string[]
+  waiting: boolean
+  handleClose: () => void
+  handleCorrectWord: (text: string) => void
+  handleLearnWord: () => void
+  language: SpellCheckLanguage
+}
+
+const B3SpellingSuggestions: FC<SpellingSuggestionsInnerProps> = ({
+  suggestions,
+  waiting,
+  language,
+  handleClose,
+  handleCorrectWord,
+  handleLearnWord,
+}) => {
+  const { t } = useTranslation()
+
+  const [selectedIndex, setSelectedIndex] = useState(0)
+
+  const itemsLength = suggestions.length + 1
+
   return (
     <ul
       className={classnames('dropdown-menu', 'dropdown-menu-unpositioned', {
@@ -113,7 +152,7 @@ export const SpellingSuggestions: FC<{
       {Array.isArray(suggestions) && (
         <>
           {suggestions.map((suggestion, index) => (
-            <ListItem
+            <BS3ListItem
               key={suggestion}
               content={suggestion}
               selected={index === selectedIndex}
@@ -126,7 +165,7 @@ export const SpellingSuggestions: FC<{
           {suggestions.length > 0 && <li className="divider" />}
         </>
       )}
-      <ListItem
+      <BS3ListItem
         content={t('add_to_dictionary')}
         selected={selectedIndex === itemsLength - 1}
         handleClick={event => {
@@ -155,7 +194,7 @@ export const SpellingSuggestions: FC<{
   )
 }
 
-const ListItem: FC<{
+const BS3ListItem: FC<{
   content: string
   selected: boolean
   handleClick: MouseEventHandler<HTMLButtonElement>
@@ -183,3 +222,94 @@ const ListItem: FC<{
     </li>
   )
 }
+
+const B5SpellingSuggestions: FC<SpellingSuggestionsInnerProps> = ({
+  suggestions,
+  waiting,
+  language,
+  handleClose,
+  handleCorrectWord,
+  handleLearnWord,
+}) => {
+  const { t } = useTranslation()
+  return (
+    <Dropdown>
+      <Dropdown.Menu
+        className={classnames('dropdown-menu', 'dropdown-menu-unpositioned', {
+          hidden: waiting,
+        })}
+        show={!waiting}
+        tabIndex={0}
+        role="menu"
+        onKeyDown={event => {
+          switch (event.code) {
+            case 'Escape':
+            case 'Tab':
+              event.preventDefault()
+              handleClose()
+              break
+          }
+        }}
+      >
+        {Array.isArray(suggestions) &&
+          suggestions.map((suggestion, index) => (
+            <BS5ListItem
+              key={suggestion}
+              content={suggestion}
+              handleClick={event => {
+                event.preventDefault()
+                handleCorrectWord(suggestion)
+              }}
+              // eslint-disable-next-line jsx-a11y/no-autofocus
+              autoFocus={index === 0}
+            />
+          ))}
+        {suggestions?.length > 0 && <Dropdown.Divider />}
+        <BS5ListItem
+          content={t('add_to_dictionary')}
+          handleClick={event => {
+            event.preventDefault()
+            handleLearnWord()
+          }}
+          // eslint-disable-next-line jsx-a11y/no-autofocus
+          autoFocus={suggestions?.length === 0}
+        />
+
+        <Dropdown.Divider />
+        <SpellingSuggestionsLanguage
+          language={language}
+          handleClose={handleClose}
+        />
+        {getMeta('ol-isSaas') && (
+          <>
+            <Dropdown.Divider />
+            <SpellingSuggestionsFeedback />
+          </>
+        )}
+      </Dropdown.Menu>
+    </Dropdown>
+  )
+}
+
+const BS5ListItem: FC<{
+  content: string
+  handleClick: MouseEventHandler<HTMLButtonElement>
+  autoFocus?: boolean
+}> = ({ content, handleClick, autoFocus }) => {
+  const handleListItem = useCallback(
+    (node: HTMLElement | null) => {
+      if (node && autoFocus) node.focus()
+    },
+    [autoFocus]
+  )
+  return (
+    <Dropdown.Item
+      role="menuitem"
+      className="btn-link text-left dropdown-menu-button"
+      onClick={handleClick}
+      ref={handleListItem}
+    >
+      {content}
+    </Dropdown.Item>
+  )
+}

+ 9 - 0
services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss

@@ -9,6 +9,15 @@
     var(--spacing-04);
 }
 
+.dropdown-menu.dropdown-menu-unpositioned {
+  position: unset;
+  top: unset;
+  left: unset;
+  z-index: unset;
+  display: block;
+  float: unset;
+}
+
 .dropdown-menu {
   @include shadow-md;