Kaynağa Gözat

Create new `SplitMenu` component and implement it for the pdf compile button (detached & non-detached) (#11772)

* Create a new shared `SplitMenu` component.

* Refactor the pdf compile button & detached compile button:
    - Rename `detach-compile-button` to `detach-compile-button-wrapper`
    - Rename `pdf-compile-button-inner` to `detach-compile-button`
    - Move some of the logic from `detach-compile-button-wrapper` to `detach-compile-button`
    - Create a new `compile-button.less` to centralize all of the compile button (detached/non-detached) custom styles rule.
    - Extract the animated striped CSS definition to the dedicated CSS file, change the class from `btn-recompile-group-has-changes` to `btn-striped-animated`
    - Refactor other className(s) appropriately according to the new component name
    - Delete the unused `changes-to-autocompile` css rule since it has not been used anywhere

* Implement the new pdf compile button with the new `SplitMenu` component.

GitOrigin-RevId: d1d055bffd311923fc47b4681605ce8ba8e26f25
M Fahru 3 yıl önce
ebeveyn
işleme
b62cb86bf8
23 değiştirilmiş dosya ile 868 ekleme ve 348 silme
  1. 23 0
      services/web/frontend/js/features/pdf-preview/components/detach-compile-button-wrapper.js
  2. 0 43
      services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js
  3. 50 0
      services/web/frontend/js/features/pdf-preview/components/detach-compile-button.tsx
  4. 0 51
      services/web/frontend/js/features/pdf-preview/components/pdf-compile-button-inner.tsx
  5. 112 97
      services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js
  6. 1 1
      services/web/frontend/js/ide/editor/controllers/CompileButton.js
  7. 1 1
      services/web/frontend/js/shared/components/icon.tsx
  8. 146 0
      services/web/frontend/js/shared/components/split-menu.tsx
  9. 10 2
      services/web/frontend/js/shared/components/tooltip.tsx
  10. 225 0
      services/web/frontend/stories/split-menu.stories.tsx
  11. 1 0
      services/web/frontend/stylesheets/_style_includes.less
  12. 1 15
      services/web/frontend/stylesheets/app/editor.less
  13. 102 0
      services/web/frontend/stylesheets/app/editor/compile-button.less
  14. 5 104
      services/web/frontend/stylesheets/app/editor/pdf.less
  15. 0 8
      services/web/frontend/stylesheets/app/editor/toolbar.less
  16. 0 13
      services/web/frontend/stylesheets/components/button-groups.less
  17. 91 0
      services/web/frontend/stylesheets/components/split-menu.less
  18. 14 8
      services/web/frontend/stylesheets/core/mixins.less
  19. 2 0
      services/web/frontend/stylesheets/core/variables.less
  20. 1 0
      services/web/frontend/stylesheets/main-style.less
  21. 5 5
      services/web/test/frontend/components/pdf-preview/detach-compile-button.spec.tsx
  22. 67 0
      services/web/test/frontend/components/shared/split-menu.spec.tsx
  23. 11 0
      services/web/types/bootstrap.ts

+ 23 - 0
services/web/frontend/js/features/pdf-preview/components/detach-compile-button-wrapper.js

@@ -0,0 +1,23 @@
+import { memo } from 'react'
+import PropTypes from 'prop-types'
+import { useLayoutContext } from '../../../shared/context/layout-context'
+import DetachCompileButton from './detach-compile-button'
+
+function DetachCompileButtonWrapper() {
+  const { detachRole, detachIsLinked } = useLayoutContext(
+    layoutContextPropTypes
+  )
+
+  if (detachRole !== 'detacher' || !detachIsLinked) {
+    return null
+  }
+
+  return <DetachCompileButton />
+}
+
+const layoutContextPropTypes = {
+  detachRole: PropTypes.string,
+  detachIsLinked: PropTypes.bool,
+}
+
+export default memo(DetachCompileButtonWrapper)

+ 0 - 43
services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js

@@ -1,43 +0,0 @@
-import { memo } from 'react'
-import PropTypes from 'prop-types'
-import classnames from 'classnames'
-import { useLayoutContext } from '../../../shared/context/layout-context'
-import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
-import PdfCompileButtonInner from './pdf-compile-button-inner'
-
-export function DetachCompileButton() {
-  const { compiling, hasChanges, startCompile } = useCompileContext()
-
-  return (
-    <div
-      className={classnames({
-        'btn-recompile-group': true,
-        'btn-recompile-group-has-changes': hasChanges,
-      })}
-    >
-      <PdfCompileButtonInner
-        startCompile={startCompile}
-        compiling={compiling}
-      />
-    </div>
-  )
-}
-
-export function DetachCompileButtonWrapper() {
-  const { detachRole, detachIsLinked } = useLayoutContext(
-    layoutContextPropTypes
-  )
-
-  if (detachRole !== 'detacher' || !detachIsLinked) {
-    return null
-  }
-
-  return <DetachCompileButton />
-}
-
-const layoutContextPropTypes = {
-  detachRole: PropTypes.string,
-  detachIsLinked: PropTypes.bool,
-}
-
-export default memo(DetachCompileButtonWrapper)

+ 50 - 0
services/web/frontend/js/features/pdf-preview/components/detach-compile-button.tsx

@@ -0,0 +1,50 @@
+import { useTranslation } from 'react-i18next'
+import { memo } from 'react'
+import { Button } from 'react-bootstrap'
+import classNames from 'classnames'
+import Icon from '../../../shared/components/icon'
+import { useDetachCompileContext } from '../../../shared/context/detach-compile-context'
+import Tooltip from '../../../shared/components/tooltip'
+
+const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
+
+function DetachCompileButton() {
+  const { t } = useTranslation()
+  const { compiling, startCompile, hasChanges } = useDetachCompileContext()
+
+  const compileButtonLabel = compiling ? `${t('compiling')}…` : t('recompile')
+  const tooltipElement = (
+    <>
+      {t('recompile_pdf')}{' '}
+      <span className="keyboard-shortcut">({modifierKey} + Enter)</span>
+    </>
+  )
+
+  return (
+    <div className="detach-compile-button-container">
+      <Tooltip
+        id="logs-toggle"
+        description={tooltipElement}
+        tooltipProps={{ className: 'keyboard-tooltip' }}
+        overlayProps={{ delayShow: 500 }}
+      >
+        <Button
+          bsStyle="primary"
+          onClick={() => startCompile()}
+          disabled={compiling}
+          className={classNames('detach-compile-button', {
+            'btn-striped-animated': hasChanges,
+            'detach-compile-button-disabled': compiling,
+          })}
+        >
+          <Icon type="refresh" spin={compiling} />
+          <span className="detach-compile-button-label">
+            {compileButtonLabel}
+          </span>
+        </Button>
+      </Tooltip>
+    </div>
+  )
+}
+
+export default memo(DetachCompileButton)

+ 0 - 51
services/web/frontend/js/features/pdf-preview/components/pdf-compile-button-inner.tsx

@@ -1,51 +0,0 @@
-import { Button } from 'react-bootstrap'
-import Tooltip from '../../../shared/components/tooltip'
-import Icon from '../../../shared/components/icon'
-import { useTranslation } from 'react-i18next'
-import { memo } from 'react'
-
-const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
-
-type PdfCompileButtonInnerProps = {
-  startCompile: () => void
-  compiling: boolean
-}
-
-function PdfCompileButtonInner({
-  startCompile,
-  compiling,
-}: PdfCompileButtonInnerProps) {
-  const { t } = useTranslation()
-
-  const compileButtonLabel = compiling ? `${t('compiling')}…` : t('recompile')
-
-  return (
-    <Tooltip
-      id="logs-toggle"
-      description={
-        <>
-          {t('recompile_pdf')}{' '}
-          <span className="keyboard-shortcut">({modifierKey} + Enter)</span>
-        </>
-      }
-      tooltipProps={{ className: 'keyboard-tooltip' }}
-      overlayProps={{ delayShow: 500 }}
-    >
-      <Button
-        className="btn-recompile"
-        bsStyle="primary"
-        onClick={() => startCompile()}
-        aria-label={compileButtonLabel}
-        disabled={compiling}
-        data-ol-loading={compiling}
-      >
-        <Icon type="refresh" spin={compiling} />
-        <span className="toolbar-hide-medium toolbar-hide-small btn-recompile-label">
-          {compileButtonLabel}
-        </span>
-      </Button>
-    </Tooltip>
-  )
-}
-
-export default memo(PdfCompileButtonInner)

+ 112 - 97
services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js

@@ -1,12 +1,12 @@
-import { Dropdown, MenuItem } from 'react-bootstrap'
-import Icon from '../../../shared/components/icon'
-import ControlledDropdown from '../../../shared/components/controlled-dropdown'
 import { useTranslation } from 'react-i18next'
 import { memo } from 'react'
-import classnames from 'classnames'
+import classNames from 'classnames'
 import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
-import PdfCompileButtonInner from './pdf-compile-button-inner'
+import SplitMenu from '../../../shared/components/split-menu'
+import Icon from '../../../shared/components/icon'
+
+const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
 
 function PdfCompileButton() {
   const {
@@ -30,100 +30,115 @@ function PdfCompileButton() {
 
   const { t } = useTranslation()
 
+  const compileButtonLabel = compiling ? `${t('compiling')}…` : t('recompile')
+  const tooltipElement = (
+    <>
+      {t('recompile_pdf')}{' '}
+      <span className="keyboard-shortcut">({modifierKey} + Enter)</span>
+    </>
+  )
+
+  const dropdownToggleClassName = classNames({
+    'detach-compile-button-animate': animateCompileDropdownArrow,
+    'btn-striped-animated': hasChanges,
+  })
+
+  const buttonClassName = classNames({
+    'btn-striped-animated': hasChanges,
+  })
+
   return (
-    <ControlledDropdown
-      className={classnames({
-        'toolbar-item': true,
-        'btn-recompile-group': true,
-        'btn-recompile-group-has-changes': hasChanges,
-      })}
-      id="pdf-recompile-dropdown"
+    <SplitMenu
+      bsStyle="primary"
+      bsSize="xs"
+      disabled={compiling}
+      button={{
+        tooltip: {
+          description: tooltipElement,
+          id: 'logs-toggle',
+          tooltipProps: { className: 'keyboard-tooltip' },
+          overlayProps: { delayShow: 500 },
+        },
+        icon: { type: 'refresh', spin: compiling },
+        onClick: () => startCompile(),
+        text: compileButtonLabel,
+        className: buttonClassName,
+      }}
+      dropdownToggle={{
+        'aria-label': t('toggle_compile_options_menu'),
+        handleAnimationEnd: () => setAnimateCompileDropdownArrow(false),
+        className: dropdownToggleClassName,
+      }}
+      dropdown={{
+        id: 'pdf-recompile-dropdown',
+      }}
     >
-      <PdfCompileButtonInner
-        startCompile={startCompile}
-        compiling={compiling}
-      />
-
-      <Dropdown.Toggle
-        aria-label={t('toggle_compile_options_menu')}
-        className={classnames({
-          'btn-recompile': true,
-          'btn-recompile-animate': animateCompileDropdownArrow,
-        })}
-        bsStyle="primary"
-        onAnimationEnd={() => {
-          setAnimateCompileDropdownArrow(false)
-        }}
-      />
-
-      <Dropdown.Menu>
-        <MenuItem header>{t('auto_compile')}</MenuItem>
-
-        <MenuItem onSelect={() => setAutoCompile(true)}>
-          <Icon type={autoCompile ? 'check' : ''} fw />
-          {t('on')}
-        </MenuItem>
-
-        <MenuItem onSelect={() => setAutoCompile(false)}>
-          <Icon type={!autoCompile ? 'check' : ''} fw />
-          {t('off')}
-        </MenuItem>
-
-        <MenuItem header>{t('compile_mode')}</MenuItem>
-
-        <MenuItem onSelect={() => setDraft(false)}>
-          <Icon type={!draft ? 'check' : ''} fw />
-          {t('normal')}
-        </MenuItem>
-
-        <MenuItem onSelect={() => setDraft(true)}>
-          <Icon type={draft ? 'check' : ''} fw />
-          {t('fast')} <span className="subdued">[draft]</span>
-        </MenuItem>
-
-        <MenuItem header>Syntax Checks</MenuItem>
-
-        <MenuItem onSelect={() => setStopOnValidationError(true)}>
-          <Icon type={stopOnValidationError ? 'check' : ''} fw />
-          {t('stop_on_validation_error')}
-        </MenuItem>
-
-        <MenuItem onSelect={() => setStopOnValidationError(false)}>
-          <Icon type={!stopOnValidationError ? 'check' : ''} fw />
-          {t('ignore_validation_errors')}
-        </MenuItem>
-
-        <MenuItem header>{t('compile_error_handling')}</MenuItem>
-
-        <MenuItem onSelect={enableStopOnFirstError}>
-          <Icon type={stopOnFirstError ? 'check' : ''} fw />
-          {t('stop_on_first_error')}
-        </MenuItem>
-
-        <MenuItem onSelect={disableStopOnFirstError}>
-          <Icon type={!stopOnFirstError ? 'check' : ''} fw />
-          {t('try_to_compile_despite_errors')}
-        </MenuItem>
-
-        <MenuItem divider />
-
-        <MenuItem
-          onSelect={() => stopCompile()}
-          disabled={!compiling}
-          aria-disabled={!compiling}
-        >
-          {t('stop_compile')}
-        </MenuItem>
-
-        <MenuItem
-          onSelect={() => recompileFromScratch()}
-          disabled={compiling}
-          aria-disabled={compiling}
-        >
-          {t('recompile_from_scratch')}
-        </MenuItem>
-      </Dropdown.Menu>
-    </ControlledDropdown>
+      <SplitMenu.Item header>{t('auto_compile')}</SplitMenu.Item>
+
+      <SplitMenu.Item onSelect={() => setAutoCompile(true)}>
+        <Icon type={autoCompile ? 'check' : ''} fw />
+        {t('on')}
+      </SplitMenu.Item>
+
+      <SplitMenu.Item onSelect={() => setAutoCompile(false)}>
+        <Icon type={!autoCompile ? 'check' : ''} fw />
+        {t('off')}
+      </SplitMenu.Item>
+
+      <SplitMenu.Item header>{t('compile_mode')}</SplitMenu.Item>
+
+      <SplitMenu.Item onSelect={() => setDraft(false)}>
+        <Icon type={!draft ? 'check' : ''} fw />
+        {t('normal')}
+      </SplitMenu.Item>
+
+      <SplitMenu.Item onSelect={() => setDraft(true)}>
+        <Icon type={draft ? 'check' : ''} fw />
+        {t('fast')} <span className="subdued">[draft]</span>
+      </SplitMenu.Item>
+
+      <SplitMenu.Item header>Syntax Checks</SplitMenu.Item>
+
+      <SplitMenu.Item onSelect={() => setStopOnValidationError(true)}>
+        <Icon type={stopOnValidationError ? 'check' : ''} fw />
+        {t('stop_on_validation_error')}
+      </SplitMenu.Item>
+
+      <SplitMenu.Item onSelect={() => setStopOnValidationError(false)}>
+        <Icon type={!stopOnValidationError ? 'check' : ''} fw />
+        {t('ignore_validation_errors')}
+      </SplitMenu.Item>
+
+      <SplitMenu.Item header>{t('compile_error_handling')}</SplitMenu.Item>
+
+      <SplitMenu.Item onSelect={enableStopOnFirstError}>
+        <Icon type={stopOnFirstError ? 'check' : ''} fw />
+        {t('stop_on_first_error')}
+      </SplitMenu.Item>
+
+      <SplitMenu.Item onSelect={disableStopOnFirstError}>
+        <Icon type={!stopOnFirstError ? 'check' : ''} fw />
+        {t('try_to_compile_despite_errors')}
+      </SplitMenu.Item>
+
+      <SplitMenu.Item divider />
+
+      <SplitMenu.Item
+        onSelect={() => stopCompile()}
+        disabled={!compiling}
+        aria-disabled={!compiling}
+      >
+        {t('stop_compile')}
+      </SplitMenu.Item>
+
+      <SplitMenu.Item
+        onSelect={() => recompileFromScratch()}
+        disabled={compiling}
+        aria-disabled={compiling}
+      >
+        {t('recompile_from_scratch')}
+      </SplitMenu.Item>
+    </SplitMenu>
   )
 }
 

+ 1 - 1
services/web/frontend/js/ide/editor/controllers/CompileButton.js

@@ -1,7 +1,7 @@
 import App from '../../../base'
 import { react2angular } from 'react2angular'
 import { rootContext } from '../../../shared/context/root-context'
-import DetachCompileButtonWrapper from '../../../features/pdf-preview/components/detach-compile-button'
+import DetachCompileButtonWrapper from '../../../features/pdf-preview/components/detach-compile-button-wrapper'
 
 App.component(
   'editorCompileButton',

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

@@ -8,7 +8,7 @@ type IconOwnProps = {
   accessibilityLabel?: string
 }
 
-type IconProps = IconOwnProps &
+export type IconProps = IconOwnProps &
   Omit<React.ComponentProps<'i'>, keyof IconOwnProps>
 
 function Icon({

+ 146 - 0
services/web/frontend/js/shared/components/split-menu.tsx

@@ -0,0 +1,146 @@
+import { Button, Dropdown, MenuItem } from 'react-bootstrap'
+import type {
+  ButtonProps,
+  MenuItemProps,
+  DropdownButtonProps,
+  DropdownProps,
+} from 'react-bootstrap'
+import type { PropsWithChildren } from 'react'
+import classNames from 'classnames'
+import Tooltip, { type TooltipProps } from './tooltip'
+import Icon, { type IconProps } from './icon'
+import type { BsSize, BsStyle } from '../../../../types/bootstrap'
+
+type SplitMenuBsStyle = Extract<BsStyle, 'primary' | 'secondary' | 'danger'>
+
+type SplitMenuBsSize = Extract<BsSize, 'md' | 'sm' | 'xs'>
+
+type SplitMenuButtonProps = {
+  tooltip?: TooltipProps
+  bsStyle?: SplitMenuBsStyle
+  text: string
+  icon?: IconProps
+} & Pick<ButtonProps, 'aria-label' | 'onClick' | 'className' | 'disabled'>
+
+type SplitMenuDropdownToggleProps = {
+  handleAnimationEnd?: () => void
+} & Pick<DropdownButtonProps, 'className' | 'aria-label'>
+
+type SplitMenuDropdownProps = Pick<DropdownProps, 'id' | 'className'>
+
+type SplitMenuProps = PropsWithChildren<{
+  bsStyle: SplitMenuBsStyle
+  bsSize?: SplitMenuBsSize
+  button: Omit<SplitMenuButtonProps, 'disabled'>
+  dropdown: SplitMenuDropdownProps
+  dropdownToggle?: SplitMenuDropdownToggleProps
+  disabled?: boolean
+}>
+
+function SplitMenu({
+  bsStyle,
+  bsSize = 'md',
+  button,
+  dropdown,
+  dropdownToggle,
+  disabled = false,
+  children,
+}: SplitMenuProps) {
+  const { tooltip, icon, ...buttonProps } = button
+
+  const splitMenuClassName = classNames('split-menu', {
+    [`btn-${bsSize}`]: true,
+  })
+
+  const dropdownToggleClassName = classNames(
+    'split-menu-dropdown-toggle',
+    dropdownToggle?.className
+  )
+
+  return (
+    <div className={splitMenuClassName}>
+      <SplitMenuButton
+        // eslint-disable-next-line react/jsx-handler-names
+        onClick={buttonProps.onClick}
+        className={buttonProps.className}
+        disabled={disabled}
+        tooltip={tooltip}
+        bsStyle={bsStyle}
+      >
+        {icon ? (
+          <Icon className="split-menu-icon" type={icon.type} spin={icon.spin} />
+        ) : null}
+        <span className="split-menu-button">{buttonProps.text}</span>
+      </SplitMenuButton>
+      <Dropdown
+        className={classNames('split-menu-dropdown', dropdown.className)}
+        id={dropdown.id}
+      >
+        <Dropdown.Toggle
+          aria-label={dropdownToggle?.['aria-label']}
+          className={dropdownToggleClassName}
+          bsStyle={bsStyle}
+          onAnimationEnd={dropdownToggle?.handleAnimationEnd}
+          data-ol-loading={disabled}
+        />
+
+        <Dropdown.Menu>{children}</Dropdown.Menu>
+      </Dropdown>
+    </div>
+  )
+}
+
+function SplitMenuButton({
+  onClick,
+  disabled,
+  tooltip,
+  bsStyle,
+  children,
+  className,
+  ...props
+}: PropsWithChildren<Omit<SplitMenuButtonProps, 'text' | 'icon'>>) {
+  const buttonClassName = classNames('split-menu-button', className)
+
+  if (tooltip) {
+    return (
+      <Tooltip
+        id={tooltip.id}
+        description={tooltip.description}
+        tooltipProps={tooltip.tooltipProps}
+        overlayProps={tooltip.overlayProps}
+      >
+        <Button
+          className={buttonClassName}
+          bsStyle={bsStyle}
+          onClick={onClick}
+          aria-label={props['aria-label']}
+          disabled={disabled}
+          data-ol-loading={disabled}
+        >
+          {children}
+        </Button>
+      </Tooltip>
+    )
+  }
+
+  return (
+    <Button
+      className={buttonClassName}
+      bsStyle={bsStyle}
+      onClick={onClick}
+      aria-label={props['aria-label']}
+      disabled={disabled}
+      data-ol-loading={disabled}
+    >
+      {children}
+    </Button>
+  )
+}
+
+function SplitMenuItem(props: MenuItemProps) {
+  return <MenuItem {...props} />
+}
+
+SplitMenu.Item = SplitMenuItem
+
+export default SplitMenu

+ 10 - 2
services/web/frontend/js/shared/components/tooltip.tsx

@@ -9,12 +9,20 @@ type OverlayProps = Omit<OverlayTriggerProps, 'overlay'> & {
   shouldUpdatePosition?: boolean // Not officially documented https://stackoverflow.com/a/43138470
 }
 
-const Tooltip: FC<{
+export type TooltipProps = {
   description: ReactNode
   id: string
   overlayProps?: OverlayProps
   tooltipProps?: BSTooltip.TooltipProps
-}> = ({ id, description, children, tooltipProps, overlayProps }) => {
+}
+
+const Tooltip: FC<TooltipProps> = ({
+  id,
+  description,
+  children,
+  tooltipProps,
+  overlayProps,
+}) => {
   return (
     <OverlayTrigger
       overlay={

+ 225 - 0
services/web/frontend/stories/split-menu.stories.tsx

@@ -0,0 +1,225 @@
+import SplitMenu from '../js/shared/components/split-menu'
+
+export const PrimaryWithoutTooltip = () => {
+  return (
+    <SplitMenu
+      bsStyle="primary"
+      button={{
+        text: 'Button',
+      }}
+      dropdown={{
+        id: 'pdf-recompile-dropdown',
+      }}
+    >
+      <SplitMenu.Item>tes</SplitMenu.Item>
+    </SplitMenu>
+  )
+}
+
+export const PrimaryWithTooltip = () => {
+  return (
+    <SplitMenu
+      bsStyle="primary"
+      button={{
+        text: 'Button',
+        tooltip: {
+          description: 'tooltip description',
+          id: 'tooltip-storybook',
+          overlayProps: {
+            placement: 'bottom',
+          },
+        },
+      }}
+      dropdown={{
+        id: 'pdf-recompile-dropdown',
+      }}
+    >
+      <SplitMenu.Item>tes</SplitMenu.Item>
+    </SplitMenu>
+  )
+}
+
+export const Disabled = () => {
+  return (
+    <div>
+      <h2>Primary</h2>
+      <SplitMenu
+        bsStyle="primary"
+        disabled
+        button={{
+          text: 'Button',
+        }}
+        dropdown={{
+          id: 'pdf-recompile-dropdown',
+        }}
+        dropdownToggle={{}}
+      >
+        <SplitMenu.Item>tes</SplitMenu.Item>
+      </SplitMenu>
+      <hr />
+      <h2>Secondary</h2>
+      <SplitMenu
+        bsStyle="secondary"
+        disabled
+        button={{
+          text: 'Button',
+        }}
+        dropdown={{
+          id: 'pdf-recompile-dropdown',
+        }}
+        dropdownToggle={{}}
+      >
+        <SplitMenu.Item>tes</SplitMenu.Item>
+      </SplitMenu>
+      <hr />
+      <h2>Danger</h2>
+      <SplitMenu
+        bsStyle="danger"
+        disabled
+        button={{
+          text: 'Button',
+        }}
+        dropdown={{
+          id: 'pdf-recompile-dropdown',
+        }}
+        dropdownToggle={{}}
+      >
+        <SplitMenu.Item>tes</SplitMenu.Item>
+      </SplitMenu>
+    </div>
+  )
+}
+
+export const DifferentSizeAndStyle = () => {
+  return (
+    <div>
+      <h2>Default (medium)</h2>
+      <div style={{ display: 'flex', gap: '10px' }}>
+        <SplitMenu
+          bsStyle="primary"
+          bsSize="md"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+        <SplitMenu
+          bsStyle="secondary"
+          bsSize="md"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+        <SplitMenu
+          bsStyle="danger"
+          bsSize="md"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+      </div>
+      <hr />
+      <h2>Small</h2>
+      <div style={{ display: 'flex', gap: '10px' }}>
+        <SplitMenu
+          bsStyle="primary"
+          bsSize="sm"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+        <SplitMenu
+          bsStyle="secondary"
+          bsSize="sm"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+        <SplitMenu
+          bsStyle="danger"
+          bsSize="sm"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+      </div>
+      <hr />
+      <h2>Extra Small</h2>
+      <div style={{ display: 'flex', gap: '10px' }}>
+        <SplitMenu
+          bsStyle="primary"
+          bsSize="xs"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+        <SplitMenu
+          bsStyle="secondary"
+          bsSize="xs"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+        <SplitMenu
+          bsStyle="danger"
+          bsSize="xs"
+          button={{
+            text: 'Button',
+          }}
+          dropdown={{
+            id: 'pdf-recompile-dropdown',
+          }}
+        >
+          <SplitMenu.Item>tes</SplitMenu.Item>
+        </SplitMenu>
+      </div>
+    </div>
+  )
+}
+
+export default {
+  title: 'Shared / Components / Split Menu',
+  component: SplitMenu,
+  args: {
+    source: 'storybook',
+  },
+}

+ 1 - 0
services/web/frontend/stylesheets/_style_includes.less

@@ -60,6 +60,7 @@
 @import 'components/expand-collapse.less';
 @import 'components/beta-badges.less';
 @import 'components/divider.less';
+@import 'components/split-menu.less';
 
 // Components w/ JavaScript
 @import 'components/modals.less';

+ 1 - 15
services/web/frontend/stylesheets/app/editor.less

@@ -17,6 +17,7 @@
 @import './editor/outline.less';
 @import './editor/logs.less';
 @import './editor/dictionary.less';
+@import './editor/compile-button.less';
 
 @ui-layout-toggler-def-height: 50px;
 @ui-resizer-size: 7px;
@@ -154,21 +155,6 @@
   overflow: hidden;
   position: relative;
   z-index: 10; // Prevent track changes showing over toolbar
-
-  .btn-recompile-group {
-    margin-right: -5px;
-    border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
-    margin-left: 6px;
-    &.btn-recompile-group-has-changes {
-      // prettier-ignore
-      #gradient > .striped(@color: rgba(255, 255, 255, 0.2), @angle: -45deg);
-      background-size: @stripe-width @stripe-width;
-      .animation(pdf-toolbar-stripes 2s linear infinite);
-    }
-    .btn-recompile {
-      border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
-    }
-  }
 }
 
 .loading-screen {

+ 102 - 0
services/web/frontend/stylesheets/app/editor/compile-button.less

@@ -0,0 +1,102 @@
+@stripe-width: 20px;
+@keyframes pdf-toolbar-stripes {
+  from {
+    background-position: 0 0;
+  }
+  to {
+    background-position: @stripe-width 0;
+  }
+}
+
+.detach-compile-button-container {
+  border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
+  margin-left: 6px;
+}
+
+.detach-compile-button-container when (@is-new-css = false) {
+  margin-right: -5px;
+}
+
+// because 2px border on :active state
+.detach-compile-button-container when (@is-new-css = true) {
+  margin-right: -3px;
+}
+
+.btn-striped-animated {
+  // prettier-ignore
+  #gradient > .striped(@color: rgba(255, 255, 255, 0.2), @angle: -45deg);
+  background-size: @stripe-width @stripe-width;
+  .animation(pdf-toolbar-stripes 2s linear infinite);
+}
+
+.detach-compile-button when (@is-new-css = false) {
+  &[disabled],
+  &[disabled].active,
+  &[disabled]:hover,
+  &[disabled]:focus {
+    background-color: mix(@btn-primary-bg, @toolbar-alt-bg-color, 65%);
+    .opacity(1);
+  }
+}
+
+.detach-compile-button {
+  height: 28px;
+  padding-top: 0;
+  padding-bottom: 0;
+
+  &.detach-compile-button-disabled {
+    &,
+    &:hover {
+      color: @white;
+      background-color: @ol-green;
+    }
+  }
+}
+
+.detach-compile-button when (@is-new-css = true) {
+  border: none;
+}
+
+.detach-compile-button-label {
+  margin-left: @line-height-computed / 4;
+}
+
+@keyframes compile-button-flash {
+  from,
+  to {
+    background: rgba(0, 0, 0, 0);
+  }
+
+  25%,
+  75% {
+    background: rgba(0, 0, 0, 0.2);
+  }
+}
+
+@keyframes compile-button-bounce {
+  from,
+  50%,
+  to {
+    transform: translateY(0);
+  }
+
+  25%,
+  75% {
+    transform: translateY(2px);
+  }
+}
+
+.detach-compile-button-animate {
+  animation-duration: 1.2s;
+  animation-fill-mode: both;
+  animation-timing-function: ease-in-out;
+  animation-name: compile-button-flash;
+}
+
+.detach-compile-button-animate .caret {
+  animation-duration: 0.6s;
+  animation-delay: 0.4s;
+  animation-fill-mode: both;
+  animation-timing-function: cubic-bezier(0.76, 0, 0.24, 1);
+  animation-name: compile-button-bounce;
+}

+ 5 - 104
services/web/frontend/stylesheets/app/editor/pdf.less

@@ -1,24 +1,8 @@
-@stripe-width: 20px;
-@keyframes pdf-toolbar-stripes {
-  from {
-    background-position: 0 0;
-  }
-  to {
-    background-position: @stripe-width 0;
-  }
-}
-
 .pdf .toolbar.toolbar-pdf {
   .toolbar-small-mixin;
   .toolbar-alt-mixin;
   padding-right: 5px;
   margin-left: 0;
-  &.changes-to-autocompile {
-    // prettier-ignore
-    #gradient > .striped(@color: rgba(255, 255, 255, 0.1), @angle: -45deg);
-    background-size: @stripe-width @stripe-width;
-    .animation(pdf-toolbar-stripes 2s linear infinite);
-  }
   .auto-compile-status {
     color: white;
     margin-right: (@line-height-computed / 2);
@@ -44,6 +28,10 @@
   flex: 1 1 100%;
 }
 
+.toolbar-pdf-left when (@is-new-css = true) {
+  margin-left: 2px;
+}
+
 .toolbar-pdf-right {
   flex: 1 0 auto;
 }
@@ -67,7 +55,7 @@
 }
 
 .toolbar-pdf-hybrid {
-  .btn:not(.btn-recompile):not(.btn-orphan):not(.detach-synctex-control):not(.switch-to-editor-btn) {
+  .btn:not(.detach-compile-button):not(.btn-orphan):not(.detach-synctex-control):not(.switch-to-editor-btn):not(.split-menu-dropdown-toggle):not(.split-menu-button) {
     display: inline-block;
     color: @toolbar-btn-color;
     background-color: transparent;
@@ -121,10 +109,6 @@
       }
     }
   }
-
-  .btn-recompile {
-    padding-top: 2px;
-  }
 }
 
 .pdf {
@@ -152,89 +136,6 @@
   }
 }
 
-.btn-recompile-group {
-  align-self: stretch;
-  margin-right: 6px;
-  border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
-  background-color: @btn-primary-bg;
-  &.btn-recompile-group-has-changes {
-    // prettier-ignore
-    #gradient > .striped(@color: rgba(255, 255, 255, 0.2), @angle: -45deg);
-    background-size: @stripe-width @stripe-width;
-    .animation(pdf-toolbar-stripes 2s linear infinite);
-  }
-}
-
-.btn-recompile {
-  height: 100%;
-  // .btn-primary;
-  color: #fff;
-  background-color: transparent;
-  padding-top: 3px;
-  padding-bottom: 3px;
-  &:first-child {
-    border-top-left-radius: 0;
-    border-bottom-left-radius: 0;
-  }
-}
-
-.btn-recompile when (@is-new-css = false) {
-  &[disabled],
-  &[disabled].active,
-  &[disabled]:hover,
-  &[disabled]:focus {
-    background-color: mix(@btn-primary-bg, @toolbar-alt-bg-color, 65%);
-    .opacity(1);
-  }
-}
-.btn-recompile when (@is-new-css = true) {
-  border: none;
-}
-
-.btn-recompile-label {
-  margin-left: @line-height-computed / 4;
-}
-
-@keyframes compile-button-flash {
-  from,
-  to {
-    background: rgba(0, 0, 0, 0);
-  }
-
-  25%,
-  75% {
-    background: rgba(0, 0, 0, 0.2);
-  }
-}
-
-@keyframes compile-button-bounce {
-  from,
-  50%,
-  to {
-    transform: translateY(0);
-  }
-
-  25%,
-  75% {
-    transform: translateY(2px);
-  }
-}
-
-.btn-recompile-animate {
-  animation-duration: 1.2s;
-  animation-fill-mode: both;
-  animation-timing-function: ease-in-out;
-  animation-name: compile-button-flash;
-}
-
-.btn-recompile-animate .caret {
-  animation-duration: 0.6s;
-  animation-delay: 0.4s;
-  animation-fill-mode: both;
-  animation-timing-function: cubic-bezier(0.76, 0, 0.24, 1);
-  animation-name: compile-button-bounce;
-}
-
 .toolbar-text {
   padding-left: @padding-xs;
 }

+ 0 - 8
services/web/frontend/stylesheets/app/editor/toolbar.less

@@ -150,14 +150,6 @@
     z-index: 1;
   }
 
-  &.toolbar-small {
-    .toolbar-small-mixin;
-  }
-
-  &.toolbar-tall {
-    .toolbar-small-mixin;
-  }
-
   &.toolbar-alt {
     .toolbar-alt-mixin;
   }

+ 0 - 13
services/web/frontend/stylesheets/components/button-groups.less

@@ -242,16 +242,3 @@
   display: flex;
   flex-wrap: nowrap;
 }
-
-// allow hiding toolbar content at various breakpoints
-.toolbar-large .toolbar-hide-large {
-  display: none !important;
-}
-
-.toolbar-medium .toolbar-hide-medium {
-  display: none !important;
-}
-
-.toolbar-small .toolbar-hide-small {
-  display: none !important;
-}

+ 91 - 0
services/web/frontend/stylesheets/components/split-menu.less

@@ -0,0 +1,91 @@
+.split-menu {
+  display: flex;
+
+  &.btn-md,
+  &.btn-sm,
+  &.btn-xs {
+    padding: 0;
+  }
+
+  &.btn-md {
+    & > .split-menu-button,
+    & > .split-menu-dropdown-toggle {
+      height: 36px;
+    }
+  }
+
+  &.btn-sm {
+    & > .split-menu-button,
+    & > .split-menu-dropdown-toggle {
+      height: 32px;
+    }
+  }
+
+  &.btn-xs {
+    & > .split-menu-button,
+    & > .split-menu-dropdown-toggle {
+      height: 28px;
+    }
+  }
+
+  &.btn-primary {
+    background-color: @ol-green;
+  }
+
+  .split-menu-icon {
+    margin-right: @line-height-computed / 4;
+  }
+
+  .split-menu-button {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+    height: 100%;
+    color: @white;
+    padding-top: 0;
+    padding-bottom: 0;
+
+    &.btn-primary {
+      border-right: 1px solid @green-10;
+    }
+
+    // on new css, btn-secondary already has a border
+    &.btn-secondary when (@is-new-css = false) {
+      border-right: 1px solid @neutral-10;
+    }
+
+    &.btn-danger {
+      border-right: 1px solid @red-10;
+    }
+
+    &[disabled] when (@is-new-css = false) {
+      opacity: 1;
+    }
+  }
+
+  .split-menu-button when (@is-new-css = true) {
+    // workaround for for the blue 2x border on the new css
+    // if z-index rule is not added, the border will overlap under the `split-menu-dropdown-toggle` since margin between both component is only 1px
+    z-index: 1;
+  }
+
+  .split-menu-dropdown {
+    float: none;
+    display: flex;
+    flex-wrap: nowrap;
+    align-self: stretch;
+    margin-right: 6px;
+
+    .split-menu-dropdown-toggle {
+      border-bottom-left-radius: 0;
+      border-top-left-radius: 0;
+      padding: 0 8px;
+
+      // on new css, btn-secondary has a border
+      // since the border between both buttons already been defined in the `split-menu-button`
+      // we will remove the rule from the dropdown toggle
+      &.btn-secondary when (@is-new-css = true) {
+        border-left: none;
+      }
+    }
+  }
+}

+ 14 - 8
services/web/frontend/stylesheets/core/mixins.less

@@ -595,10 +595,13 @@
   }
 
   &[data-ol-loading='true'] {
-    // use the default state colors when in a loading state
-    color: @btn-bordered-color!important;
-    background-color: @btn-bordered-background-color!important;
-    border-color: @btn-bordered-border-color!important;
+    &,
+    &:hover {
+      // use the default state colors when in a loading state
+      color: @btn-bordered-color!important;
+      background-color: @btn-bordered-background-color!important;
+      border-color: @btn-bordered-border-color!important;
+    }
   }
 }
 
@@ -651,10 +654,13 @@
   }
 
   &[data-ol-loading='true'] {
-    // use the default state colors when in a loading state
-    color: @btn-borderless-color!important;
-    background-color: @btn-borderless-background-color!important;
-    border-color: @btn-borderless-border-color!important;
+    &,
+    &:hover {
+      // use the default state colors when in a loading state
+      color: @btn-borderless-color!important;
+      background-color: @btn-borderless-background-color!important;
+      border-color: @btn-borderless-border-color;
+    }
   }
 }
 

+ 2 - 0
services/web/frontend/stylesheets/core/variables.less

@@ -11,6 +11,8 @@
 @white: #ffffff;
 @neutral-20: #e7e9ee;
 @neutral-90: #1b222c;
+@neutral-40: #afb5c0;
+@neutral-10: #f4f5f6;
 
 // Styleguide colors
 @ol-blue-gray-0: #f4f5f8;

+ 1 - 0
services/web/frontend/stylesheets/main-style.less

@@ -71,6 +71,7 @@
 @import 'components/divider.less';
 @import 'components/input-switch.less';
 @import 'components/container.less';
+@import 'components/split-menu.less';
 
 // Components w/ JavaScript
 @import 'components/modals.less';

+ 5 - 5
services/web/test/frontend/components/pdf-preview/detach-compile-button.spec.tsx

@@ -1,9 +1,9 @@
 import { EditorProviders } from '../../helpers/editor-providers'
-import DetachCompileButton from '../../../../frontend/js/features/pdf-preview/components/detach-compile-button'
+import DetachCompileButtonWrapper from '../../../../frontend/js/features/pdf-preview/components/detach-compile-button-wrapper'
 import { mockScope } from './scope'
 import { testDetachChannel } from '../../helpers/detach-channel'
 
-describe('<DetachCompileButton/>', function () {
+describe('<DetachCompileButtonWrapper />', function () {
   beforeEach(function () {
     cy.interceptCompile()
     cy.interceptEvents()
@@ -22,7 +22,7 @@ describe('<DetachCompileButton/>', function () {
 
     cy.mount(
       <EditorProviders scope={scope}>
-        <DetachCompileButton />
+        <DetachCompileButtonWrapper />
       </EditorProviders>
     )
 
@@ -38,7 +38,7 @@ describe('<DetachCompileButton/>', function () {
 
     cy.mount(
       <EditorProviders scope={scope}>
-        <DetachCompileButton />
+        <DetachCompileButtonWrapper />
       </EditorProviders>
     )
 
@@ -61,7 +61,7 @@ describe('<DetachCompileButton/>', function () {
 
     cy.mount(
       <EditorProviders scope={scope}>
-        <DetachCompileButton />
+        <DetachCompileButtonWrapper />
       </EditorProviders>
     )
 

+ 67 - 0
services/web/test/frontend/components/shared/split-menu.spec.tsx

@@ -0,0 +1,67 @@
+import SplitMenu from '../../../../frontend/js/shared/components/split-menu'
+
+describe('SplitMenu', function () {
+  it('renders primary variant', function () {
+    cy.mount(
+      <SplitMenu
+        bsStyle="primary"
+        button={{
+          text: 'Button Text',
+        }}
+        dropdown={{
+          id: 'pdf-recompile-dropdown',
+        }}
+      >
+        <SplitMenu.Item>Item 1</SplitMenu.Item>
+        <SplitMenu.Item>Item 2</SplitMenu.Item>
+        <SplitMenu.Item>Item 3</SplitMenu.Item>
+      </SplitMenu>
+    )
+
+    cy.get('button.split-menu-button').contains('Button Text')
+    cy.get('button.split-menu-button').should('have.class', 'btn-primary')
+    cy.get('button.split-menu-dropdown-toggle').should(
+      'have.class',
+      'btn-primary'
+    )
+    cy.get('li').should('have.length', 3)
+    cy.get('li').contains('Item 1')
+    cy.get('li').contains('Item 2')
+    cy.get('li').contains('Item 3')
+
+    cy.get('ul.dropdown-menu').should('not.be.visible')
+    cy.get('button.split-menu-dropdown-toggle').click()
+    cy.get('ul.dropdown-menu').should('be.visible')
+  })
+
+  it('with custom classNames', function () {
+    cy.mount(
+      <SplitMenu
+        bsStyle="primary"
+        button={{
+          text: 'Button Text',
+          className: 'split-menu-class-1',
+        }}
+        dropdown={{
+          id: 'pdf-recompile-dropdown',
+          className: 'split-menu-class-2',
+        }}
+        dropdownToggle={{
+          className: 'split-menu-class-3',
+        }}
+      >
+        <SplitMenu.Item>Item 1</SplitMenu.Item>
+      </SplitMenu>
+    )
+
+    cy.get('button.split-menu-button').should(
+      'have.class',
+      'split-menu-class-1'
+    )
+    cy.get('div.split-menu-dropdown').should('have.class', 'split-menu-class-2')
+    cy.get('button.split-menu-dropdown-toggle').should(
+      'have.class',
+      'split-menu-class-3'
+    )
+  })
+})

+ 11 - 0
services/web/types/bootstrap.ts

@@ -0,0 +1,11 @@
+export type BsStyle =
+  | 'primary'
+  | 'secondary'
+  | 'danger'
+  | 'info'
+  | 'default'
+  | 'link'
+  | 'warning'
+  | 'success'
+
+export type BsSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'