Просмотр исходного кода

Merge pull request #17806 from overleaf/rd-bootstrap-button2

[web] - Updating the Account Settings page with the Button and Icon Button wrappers

GitOrigin-RevId: 135c4ddaa64d009d3ab8cdfef9cff899fd77669c
Rebeka Dekany 2 лет назад
Родитель
Сommit
fa3f51fb2e
21 измененных файлов с 211 добавлено и 151 удалено
  1. 11 13
      services/web/frontend/js/features/settings/components/account-info-section.tsx
  2. 13 7
      services/web/frontend/js/features/settings/components/emails/actions/make-primary/primary-button.tsx
  3. 24 10
      services/web/frontend/js/features/settings/components/emails/actions/remove.tsx
  4. 4 4
      services/web/frontend/js/features/settings/components/emails/add-email.tsx
  5. 2 2
      services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
  6. 2 2
      services/web/frontend/js/features/settings/components/emails/downshift-input.tsx
  7. 5 5
      services/web/frontend/js/features/settings/components/emails/header.tsx
  8. 3 3
      services/web/frontend/js/features/settings/components/emails/row.tsx
  9. 9 11
      services/web/frontend/js/features/settings/components/password-section.tsx
  10. 14 3
      services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx
  11. 11 11
      services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx
  12. 1 1
      services/web/frontend/js/features/ui/components/bootstrap-5/icon-button.tsx
  13. 0 26
      services/web/frontend/js/features/ui/components/bootstrap-5/icon-text-button.tsx
  14. 49 0
      services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/button-wrapper.tsx
  15. 45 0
      services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/icon-button-wrapper.tsx
  16. 3 0
      services/web/frontend/js/features/ui/components/types/button-props.ts
  17. 0 6
      services/web/frontend/js/features/ui/components/types/icon-text-button-props.ts
  18. 1 1
      services/web/frontend/js/features/utils/bootstrap-5.ts
  19. 2 4
      services/web/frontend/js/shared/components/icon.tsx
  20. 12 0
      services/web/frontend/stories/ui/button.stories.tsx
  21. 0 42
      services/web/frontend/stories/ui/icon-text-button.stories.tsx

+ 11 - 13
services/web/frontend/js/features/settings/components/account-info-section.tsx

@@ -1,11 +1,5 @@
 import { useState } from 'react'
 import { useState } from 'react'
-import {
-  Alert,
-  Button,
-  ControlLabel,
-  FormControl,
-  FormGroup,
-} from 'react-bootstrap'
+import { Alert, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import {
 import {
   getUserFacingMessage,
   getUserFacingMessage,
@@ -15,6 +9,7 @@ import getMeta from '../../../utils/meta'
 import { ExposedSettings } from '../../../../../types/exposed-settings'
 import { ExposedSettings } from '../../../../../types/exposed-settings'
 import useAsync from '../../../shared/hooks/use-async'
 import useAsync from '../../../shared/hooks/use-async'
 import { useUserContext } from '../../../shared/context/user-context'
 import { useUserContext } from '../../../shared/context/user-context'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 
 function AccountInfoSection() {
 function AccountInfoSection() {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -118,14 +113,17 @@ function AccountInfoSection() {
           </FormGroup>
           </FormGroup>
         ) : null}
         ) : null}
         {canUpdateEmail || canUpdateNames ? (
         {canUpdateEmail || canUpdateNames ? (
-          <Button
-            form="account-info-form"
+          <ButtonWrapper
             type="submit"
             type="submit"
-            bsStyle="primary"
-            disabled={isLoading || !isFormValid}
+            variant="primary"
+            form="account-info-form"
+            isLoading={isLoading || !isFormValid}
+            bs3Props={{
+              loading: isLoading ? `${t('saving')}…` : t('update'),
+            }}
           >
           >
-            {isLoading ? <>{t('saving')}…</> : t('update')}
-          </Button>
+            {t('update')}
+          </ButtonWrapper>
         ) : null}
         ) : null}
       </form>
       </form>
     </>
     </>

+ 13 - 7
services/web/frontend/js/features/settings/components/emails/actions/make-primary/primary-button.tsx

@@ -1,16 +1,22 @@
-import { Button } from 'react-bootstrap'
+import ButtonWrapper, {
+  ButtonWrapperProps,
+} from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 
-function PrimaryButton({ children, disabled, onClick }: Button.ButtonProps) {
+function PrimaryButton({ children, disabled, onClick }: ButtonWrapperProps) {
   return (
   return (
-    <Button
-      bsSize="small"
-      bsStyle={null}
-      className="btn-secondary-info btn-secondary"
+    <ButtonWrapper
+      size="small"
       disabled={disabled}
       disabled={disabled}
       onClick={onClick}
       onClick={onClick}
+      variant="secondary"
+      bs3Props={{ bsStyle: null }}
+      className={bsVersion({
+        bs3: 'btn-secondary btn-secondary-info',
+      })}
     >
     >
       {children}
       {children}
-    </Button>
+    </ButtonWrapper>
   )
   )
 }
 }
 
 

+ 24 - 10
services/web/frontend/js/features/settings/components/emails/actions/remove.tsx

@@ -1,24 +1,38 @@
-import Icon from '../../../../../shared/components/icon'
-import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { UserEmailData } from '../../../../../../../types/user-email'
 import { UserEmailData } from '../../../../../../../types/user-email'
 import { useUserEmailsContext } from '../../../context/user-email-context'
 import { useUserEmailsContext } from '../../../context/user-email-context'
 import { postJSON } from '../../../../../infrastructure/fetch-json'
 import { postJSON } from '../../../../../infrastructure/fetch-json'
 import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
 import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
 import TooltipWrapper from '@/features/ui/components/bootstrap-5/wrappers/tooltip-wrapper'
 import TooltipWrapper from '@/features/ui/components/bootstrap-5/wrappers/tooltip-wrapper'
+import IconButtonWrapper, {
+  IconButtonWrapperProps,
+} from '@/features/ui/components/bootstrap-5/wrappers/icon-button-wrapper'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 
-function DeleteButton({ disabled, onClick }: Button.ButtonProps) {
+type DeleteButtonProps = Pick<
+  IconButtonWrapperProps,
+  'disabled' | 'isLoading' | 'onClick'
+>
+
+function DeleteButton({ disabled, isLoading, onClick }: DeleteButtonProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
   return (
   return (
-    <Button
-      bsSize="small"
-      bsStyle="danger"
+    <IconButtonWrapper
+      variant="danger"
       disabled={disabled}
       disabled={disabled}
+      isLoading={isLoading}
+      size="small"
       onClick={onClick}
       onClick={onClick}
-    >
-      <Icon type="trash" fw accessibilityLabel={t('remove')} />
-    </Button>
+      accessibilityLabel={t('remove') || ''}
+      icon={
+        bsVersion({
+          bs5: 'delete',
+          bs3: 'trash',
+        }) || 'trash'
+      }
+      bs3Props={{ fw: true }}
+    />
   )
   )
 }
 }
 
 
@@ -49,7 +63,7 @@ function Remove({ userEmailData, deleteEmailAsync }: RemoveProps) {
   }
   }
 
 
   if (deleteEmailAsync.isLoading) {
   if (deleteEmailAsync.isLoading) {
-    return <DeleteButton disabled />
+    return <DeleteButton isLoading />
   }
   }
 
 
   return (
   return (

+ 4 - 4
services/web/frontend/js/features/settings/components/emails/add-email.tsx

@@ -18,7 +18,7 @@ import getMeta from '../../../../utils/meta'
 import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
 import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
 import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
 import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
 import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
 import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
-import { bsClassName } from '@/features/utils/bootstrap-5'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 
 function AddEmail() {
 function AddEmail() {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -136,7 +136,7 @@ function AddEmail() {
     <>
     <>
       <label
       <label
         htmlFor="affiliations-email"
         htmlFor="affiliations-email"
-        className={bsClassName({ bs5: 'visually-hidden', bs3: 'sr-only' })}
+        className={bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })}
       >
       >
         {t('email')}
         {t('email')}
       </label>
       </label>
@@ -162,7 +162,7 @@ function AddEmail() {
           </ColWrapper>
           </ColWrapper>
           <ColWrapper md={4}>
           <ColWrapper md={4}>
             <Cell
             <Cell
-              className={bsClassName({
+              className={bsVersion({
                 bs5: 'text-md-end',
                 bs5: 'text-md-end',
                 bs3: 'text-md-right',
                 bs3: 'text-md-right',
               })}
               })}
@@ -207,7 +207,7 @@ function AddEmail() {
         {!isSsoAvailableForDomain ? (
         {!isSsoAvailableForDomain ? (
           <ColWrapper md={4}>
           <ColWrapper md={4}>
             <Cell
             <Cell
-              className={bsClassName({
+              className={bsVersion({
                 bs5: 'text-md-end',
                 bs5: 'text-md-end',
                 bs3: 'text-md-right',
                 bs3: 'text-md-right',
               })}
               })}

+ 2 - 2
services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
 import { useCombobox } from 'downshift'
 import { useCombobox } from 'downshift'
 import classnames from 'classnames'
 import classnames from 'classnames'
 import countries, { CountryCode } from '../../../data/countries-list'
 import countries, { CountryCode } from '../../../data/countries-list'
-import { bsClassName } from '@/features/utils/bootstrap-5'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 
 type CountryInputProps = {
 type CountryInputProps = {
   setValue: React.Dispatch<React.SetStateAction<CountryCode | null>>
   setValue: React.Dispatch<React.SetStateAction<CountryCode | null>>
@@ -58,7 +58,7 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
         {/* eslint-disable-next-line jsx-a11y/label-has-for */}
         {/* eslint-disable-next-line jsx-a11y/label-has-for */}
         <label
         <label
           {...getLabelProps()}
           {...getLabelProps()}
-          className={bsClassName({ bs5: 'visually-hidden', bs3: 'sr-only' })}
+          className={bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })}
         >
         >
           {t('country')}
           {t('country')}
         </label>
         </label>

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

@@ -2,7 +2,7 @@ import { useState, useEffect, forwardRef } from 'react'
 import { useCombobox } from 'downshift'
 import { useCombobox } from 'downshift'
 import classnames from 'classnames'
 import classnames from 'classnames'
 import { escapeRegExp } from 'lodash'
 import { escapeRegExp } from 'lodash'
-import { bsClassName } from '@/features/utils/bootstrap-5'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 
 type DownshiftInputProps = {
 type DownshiftInputProps = {
   highlightMatches?: boolean
   highlightMatches?: boolean
@@ -92,7 +92,7 @@ function Downshift({
           className={
           className={
             showLabel
             showLabel
               ? ''
               ? ''
-              : bsClassName({ bs5: 'visually-hidden', bs3: 'sr-only' })
+              : bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })
           }
           }
         >
         >
           {label}
           {label}

+ 5 - 5
services/web/frontend/js/features/settings/components/emails/header.tsx

@@ -3,7 +3,7 @@ import EmailCell from './cell'
 import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
 import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
 import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
 import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
 import classnames from 'classnames'
 import classnames from 'classnames'
-import { bsClassName } from '@/features/utils/bootstrap-5'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 
 function Header() {
 function Header() {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -13,7 +13,7 @@ function Header() {
       <RowWrapper>
       <RowWrapper>
         <ColWrapper
         <ColWrapper
           md={4}
           md={4}
-          className={bsClassName({
+          className={bsVersion({
             bs5: 'd-none d-sm-block',
             bs5: 'd-none d-sm-block',
             bs3: 'hidden-xs',
             bs3: 'hidden-xs',
           })}
           })}
@@ -24,7 +24,7 @@ function Header() {
         </ColWrapper>
         </ColWrapper>
         <ColWrapper
         <ColWrapper
           md={8}
           md={8}
-          className={bsClassName({
+          className={bsVersion({
             bs5: 'd-none d-sm-block',
             bs5: 'd-none d-sm-block',
             bs3: 'hidden-xs',
             bs3: 'hidden-xs',
           })}
           })}
@@ -36,13 +36,13 @@ function Header() {
       </RowWrapper>
       </RowWrapper>
       <div
       <div
         className={classnames(
         className={classnames(
-          bsClassName({ bs5: 'd-none d-sm-block', bs3: 'hidden-xs' }),
+          bsVersion({ bs5: 'd-none d-sm-block', bs3: 'hidden-xs' }),
           'horizontal-divider'
           'horizontal-divider'
         )}
         )}
       />
       />
       <div
       <div
         className={classnames(
         className={classnames(
-          bsClassName({ bs5: 'd-none d-sm-block', bs3: 'hidden-xs' }),
+          bsVersion({ bs5: 'd-none d-sm-block', bs3: 'hidden-xs' }),
           'horizontal-divider'
           'horizontal-divider'
         )}
         )}
       />
       />

+ 3 - 3
services/web/frontend/js/features/settings/components/emails/row.tsx

@@ -15,7 +15,7 @@ import ReconfirmationInfo from './reconfirmation-info'
 import { useLocation } from '../../../../shared/hooks/use-location'
 import { useLocation } from '../../../../shared/hooks/use-location'
 import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
 import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
 import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
 import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
-import { bsClassName } from '@/features/utils/bootstrap-5'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 
 type EmailsRowProps = {
 type EmailsRowProps = {
   userEmailData: UserEmailData
   userEmailData: UserEmailData
@@ -44,7 +44,7 @@ function EmailsRow({ userEmailData }: EmailsRowProps) {
         </ColWrapper>
         </ColWrapper>
         <ColWrapper md={3}>
         <ColWrapper md={3}>
           <EmailCell
           <EmailCell
-            className={bsClassName({
+            className={bsVersion({
               bs5: 'text-md-end',
               bs5: 'text-md-end',
               bs3: 'text-md-right',
               bs3: 'text-md-right',
             })}
             })}
@@ -154,7 +154,7 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
           </ColWrapper>
           </ColWrapper>
           <ColWrapper
           <ColWrapper
             md={3}
             md={3}
-            className={bsClassName({
+            className={bsVersion({
               bs5: 'text-md-end',
               bs5: 'text-md-end',
               bs3: 'text-md-right',
               bs3: 'text-md-right',
             })}
             })}

+ 9 - 11
services/web/frontend/js/features/settings/components/password-section.tsx

@@ -1,11 +1,5 @@
 import { useEffect, useState } from 'react'
 import { useEffect, useState } from 'react'
-import {
-  Alert,
-  Button,
-  ControlLabel,
-  FormControl,
-  FormGroup,
-} from 'react-bootstrap'
+import { Alert, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
 import { Trans, useTranslation } from 'react-i18next'
 import {
 import {
   getUserFacingMessage,
   getUserFacingMessage,
@@ -16,6 +10,7 @@ import getMeta from '../../../utils/meta'
 import { ExposedSettings } from '../../../../../types/exposed-settings'
 import { ExposedSettings } from '../../../../../types/exposed-settings'
 import { PasswordStrengthOptions } from '../../../../../types/password-strength-options'
 import { PasswordStrengthOptions } from '../../../../../types/password-strength-options'
 import useAsync from '../../../shared/hooks/use-async'
 import useAsync from '../../../shared/hooks/use-async'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 
 type PasswordUpdateResult = {
 type PasswordUpdateResult = {
   message?: {
   message?: {
@@ -198,14 +193,17 @@ function PasswordForm() {
           </Alert>
           </Alert>
         </FormGroup>
         </FormGroup>
       ) : null}
       ) : null}
-      <Button
+      <ButtonWrapper
         form="password-change-form"
         form="password-change-form"
         type="submit"
         type="submit"
-        bsStyle="primary"
+        variant="primary"
         disabled={isLoading || !isFormValid}
         disabled={isLoading || !isFormValid}
+        bs3Props={{
+          loading: isLoading ? `${t('saving')}…` : t('change'),
+        }}
       >
       >
-        {isLoading ? <>{t('saving')}…</> : t('change')}
-      </Button>
+        {t('change')}
+      </ButtonWrapper>
     </form>
     </form>
   )
   )
 }
 }

+ 14 - 3
services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx

@@ -1,7 +1,8 @@
-import { Button as B5Button, Spinner } from 'react-bootstrap-5'
+import { Button as BS5Button, Spinner } from 'react-bootstrap-5'
 import type { ButtonProps } from '@/features/ui/components/types/button-props'
 import type { ButtonProps } from '@/features/ui/components/types/button-props'
 import classNames from 'classnames'
 import classNames from 'classnames'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
+import MaterialIcon from '@/shared/components/material-icon'
 
 
 const sizeClasses = new Map<ButtonProps['size'], string>([
 const sizeClasses = new Map<ButtonProps['size'], string>([
   ['small', 'btn-sm'],
   ['small', 'btn-sm'],
@@ -12,8 +13,11 @@ const sizeClasses = new Map<ButtonProps['size'], string>([
 export default function Button({
 export default function Button({
   children,
   children,
   className,
   className,
+  leadingIcon,
   isLoading = false,
   isLoading = false,
   size = 'default',
   size = 'default',
+  trailingIcon,
+  variant = 'primary',
   ...props
   ...props
 }: ButtonProps) {
 }: ButtonProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -24,9 +28,10 @@ export default function Button({
   })
   })
   const loadingSpinnerClassName =
   const loadingSpinnerClassName =
     size === 'large' ? 'loading-spinner-large' : 'loading-spinner-small'
     size === 'large' ? 'loading-spinner-large' : 'loading-spinner-small'
+  const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small'
 
 
   return (
   return (
-    <B5Button className={buttonClassName} {...props}>
+    <BS5Button className={buttonClassName} variant={variant} {...props}>
       {isLoading && (
       {isLoading && (
         <span className="spinner-container">
         <span className="spinner-container">
           <Spinner
           <Spinner
@@ -40,8 +45,14 @@ export default function Button({
         </span>
         </span>
       )}
       )}
       <span className="button-content" aria-hidden={isLoading}>
       <span className="button-content" aria-hidden={isLoading}>
+        {leadingIcon && (
+          <MaterialIcon type={leadingIcon} className={materialIconClassName} />
+        )}
         {children}
         {children}
+        {trailingIcon && (
+          <MaterialIcon type={trailingIcon} className={materialIconClassName} />
+        )}
       </span>
       </span>
-    </B5Button>
+    </BS5Button>
   )
   )
 }
 }

+ 11 - 11
services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx

@@ -1,10 +1,10 @@
 import React from 'react'
 import React from 'react'
 import {
 import {
-  Dropdown as B5Dropdown,
-  DropdownToggle as B5DropdownToggle,
-  DropdownMenu as B5DropdownMenu,
-  DropdownItem as B5DropdownItem,
-  DropdownDivider as B5DropdownDivider,
+  Dropdown as BS5Dropdown,
+  DropdownToggle as BS5DropdownToggle,
+  DropdownMenu as BS5DropdownMenu,
+  DropdownItem as BS5DropdownItem,
+  DropdownDivider as BS5DropdownDivider,
 } from 'react-bootstrap-5'
 } from 'react-bootstrap-5'
 import type {
 import type {
   DropdownProps,
   DropdownProps,
@@ -15,7 +15,7 @@ import type {
 import MaterialIcon from '@/shared/components/material-icon'
 import MaterialIcon from '@/shared/components/material-icon'
 
 
 export function Dropdown({ ...props }: DropdownProps) {
 export function Dropdown({ ...props }: DropdownProps) {
-  return <B5Dropdown {...props} />
+  return <BS5Dropdown {...props} />
 }
 }
 
 
 export function DropdownItem({
 export function DropdownItem({
@@ -29,7 +29,7 @@ export function DropdownItem({
   const trailingIconType = active ? 'check' : trailingIcon
   const trailingIconType = active ? 'check' : trailingIcon
   return (
   return (
     <li>
     <li>
-      <B5DropdownItem
+      <BS5DropdownItem
         active={active}
         active={active}
         className={description ? 'dropdown-item-description-container' : ''}
         className={description ? 'dropdown-item-description-container' : ''}
         role="menuitem"
         role="menuitem"
@@ -51,19 +51,19 @@ export function DropdownItem({
         {description && (
         {description && (
           <span className="dropdown-item-description">{description}</span>
           <span className="dropdown-item-description">{description}</span>
         )}
         )}
-      </B5DropdownItem>
+      </BS5DropdownItem>
     </li>
     </li>
   )
   )
 }
 }
 
 
 export function DropdownToggle({ ...props }: DropdownToggleProps) {
 export function DropdownToggle({ ...props }: DropdownToggleProps) {
-  return <B5DropdownToggle {...props} />
+  return <BS5DropdownToggle {...props} />
 }
 }
 
 
 export function DropdownMenu({ as = 'ul', ...props }: DropdownMenuProps) {
 export function DropdownMenu({ as = 'ul', ...props }: DropdownMenuProps) {
-  return <B5DropdownMenu as={as} role="menubar" {...props} />
+  return <BS5DropdownMenu as={as} role="menubar" {...props} />
 }
 }
 
 
 export function DropdownDivider() {
 export function DropdownDivider() {
-  return <B5DropdownDivider aria-hidden="true" />
+  return <BS5DropdownDivider aria-hidden="true" />
 }
 }

+ 1 - 1
services/web/frontend/js/features/ui/components/bootstrap-5/icon-button.tsx

@@ -1,7 +1,7 @@
+import classNames from 'classnames'
 import MaterialIcon from '@/shared/components/material-icon'
 import MaterialIcon from '@/shared/components/material-icon'
 import Button from './button'
 import Button from './button'
 import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
 import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
-import classNames from 'classnames'
 
 
 export default function IconButton({
 export default function IconButton({
   accessibilityLabel,
   accessibilityLabel,

+ 0 - 26
services/web/frontend/js/features/ui/components/bootstrap-5/icon-text-button.tsx

@@ -1,26 +0,0 @@
-import MaterialIcon from '@/shared/components/material-icon'
-import { IconTextButtonProps } from '../types/icon-text-button-props'
-import Button from './button'
-
-export default function IconTextButton({
-  children,
-  className,
-  leadingIcon,
-  size = 'default',
-  trailingIcon,
-  ...props
-}: IconTextButtonProps) {
-  const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small'
-
-  return (
-    <Button size={size} {...props}>
-      {leadingIcon && (
-        <MaterialIcon type={leadingIcon} className={materialIconClassName} />
-      )}
-      {children}
-      {trailingIcon && (
-        <MaterialIcon type={trailingIcon} className={materialIconClassName} />
-      )}
-    </Button>
-  )
-}

+ 49 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/button-wrapper.tsx

@@ -0,0 +1,49 @@
+import BootstrapVersionSwitcher from '../bootstrap-version-switcher'
+import { Button as BS3Button } from 'react-bootstrap'
+import type { ButtonProps } from '@/features/ui/components/types/button-props'
+import type { ButtonProps as BS3ButtonPropsBase } from 'react-bootstrap'
+import Button from '../button'
+
+export type ButtonWrapperProps = ButtonProps & {
+  bs3Props?: {
+    bsStyle?: string | null
+    loading?: React.ReactNode
+  }
+}
+
+// Resolve type mismatch of the onClick event handler
+export type BS3ButtonProps = Omit<BS3ButtonPropsBase, 'onClick'> & {
+  onClick?: React.MouseEventHandler<any>
+}
+
+// maps Bootstrap 5 sizes to Bootstrap 3 sizes
+export const mapBsButtonSizes = (
+  size: ButtonProps['size']
+): 'sm' | 'lg' | undefined =>
+  size === 'small' ? 'sm' : size === 'large' ? 'lg' : undefined
+
+export default function ButtonWrapper(props: ButtonWrapperProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3ButtonProps: BS3ButtonProps = {
+    bsStyle: rest.variant,
+    bsSize: mapBsButtonSizes(rest.size),
+    className: rest.className,
+    disabled: rest.isLoading || rest.disabled,
+    form: rest.form,
+    onClick: rest.onClick,
+    type: rest.type,
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3Button {...bs3ButtonProps}>
+          {bs3Props?.loading || rest.children}
+        </BS3Button>
+      }
+      bs5={<Button {...rest} />}
+    />
+  )
+}

+ 45 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/icon-button-wrapper.tsx

@@ -0,0 +1,45 @@
+import { BS3ButtonProps, mapBsButtonSizes } from './button-wrapper'
+import { Button as BS3Button } from 'react-bootstrap'
+import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
+import BootstrapVersionSwitcher from '../bootstrap-version-switcher'
+import Icon, { IconProps } from '@/shared/components/icon'
+import IconButton from '../icon-button'
+
+export type IconButtonWrapperProps = IconButtonProps & {
+  bs3Props?: {
+    loading?: React.ReactNode
+    fw?: IconProps['fw']
+  }
+}
+
+export default function IconButtonWrapper(props: IconButtonWrapperProps) {
+  const { bs3Props, ...rest } = props
+
+  const { fw, ...filterBs3Props } = bs3Props || {}
+
+  const bs3ButtonProps: BS3ButtonProps = {
+    bsStyle: rest.variant,
+    bsSize: mapBsButtonSizes(rest.size),
+    disabled: rest.isLoading || rest.disabled,
+    form: rest.form,
+    onClick: rest.onClick,
+    type: rest.type,
+    ...filterBs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3Button {...bs3ButtonProps}>
+          {bs3Props?.loading}
+          <Icon
+            type={rest.icon}
+            fw={fw}
+            accessibilityLabel={rest.accessibilityLabel}
+          />
+        </BS3Button>
+      }
+      bs5={<IconButton {...rest} />}
+    />
+  )
+}

+ 3 - 0
services/web/frontend/js/features/ui/components/types/button-props.ts

@@ -4,10 +4,13 @@ export type ButtonProps = {
   children?: ReactNode
   children?: ReactNode
   className?: string
   className?: string
   disabled?: boolean
   disabled?: boolean
+  form?: string
+  leadingIcon?: string
   href?: string
   href?: string
   isLoading?: boolean
   isLoading?: boolean
   onClick?: MouseEventHandler<HTMLButtonElement>
   onClick?: MouseEventHandler<HTMLButtonElement>
   size?: 'small' | 'default' | 'large'
   size?: 'small' | 'default' | 'large'
+  trailingIcon?: string
   type?: 'button' | 'reset' | 'submit'
   type?: 'button' | 'reset' | 'submit'
   variant?:
   variant?:
     | 'primary'
     | 'primary'

+ 0 - 6
services/web/frontend/js/features/ui/components/types/icon-text-button-props.ts

@@ -1,6 +0,0 @@
-import { ButtonProps } from './button-props'
-
-export type IconTextButtonProps = ButtonProps & {
-  leadingIcon?: string
-  trailingIcon?: string
-}

+ 1 - 1
services/web/frontend/js/features/utils/bootstrap-5.ts

@@ -2,6 +2,6 @@ import getMeta from '@/utils/meta'
 
 
 export const isBootstrap5 = getMeta('ol-bootstrapVersion') === 5
 export const isBootstrap5 = getMeta('ol-bootstrapVersion') === 5
 
 
-export const bsClassName = ({ bs5, bs3 }: { bs5: string; bs3: string }) => {
+export const bsVersion = ({ bs5, bs3 }: { bs5?: string; bs3?: string }) => {
   return isBootstrap5 ? bs5 : bs3
   return isBootstrap5 ? bs5 : bs3
 }
 }

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

@@ -1,5 +1,5 @@
 import classNames from 'classnames'
 import classNames from 'classnames'
-import { bsClassName } from '@/features/utils/bootstrap-5'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 
 type IconOwnProps = {
 type IconOwnProps = {
   type: string
   type: string
@@ -36,9 +36,7 @@ function Icon({
     <>
     <>
       <i className={iconClassName} aria-hidden="true" {...rest} />
       <i className={iconClassName} aria-hidden="true" {...rest} />
       {accessibilityLabel && (
       {accessibilityLabel && (
-        <span
-          className={bsClassName({ bs5: 'visually-hidden', bs3: 'sr-only' })}
-        >
+        <span className={bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })}>
           {accessibilityLabel}
           {accessibilityLabel}
         </span>
         </span>
       )}
       )}

+ 12 - 0
services/web/frontend/stories/ui/button.stories.tsx

@@ -7,6 +7,18 @@ export const NewButton = (args: Args) => {
   return <Button {...args} />
   return <Button {...args} />
 }
 }
 
 
+export const ButtonWithLeadingIcon = (args: Args) => {
+  return <Button leadingIcon="add" {...args} />
+}
+
+export const ButtonWithTrailingIcon = (args: Args) => {
+  return <Button trailingIcon="add" {...args} />
+}
+
+export const ButtonWithIcons = (args: Args) => {
+  return <Button trailingIcon="add" leadingIcon="add" {...args} />
+}
+
 const meta: Meta<typeof Button> = {
 const meta: Meta<typeof Button> = {
   title: 'Shared / Components / Bootstrap 5 / Button',
   title: 'Shared / Components / Bootstrap 5 / Button',
   component: Button,
   component: Button,

+ 0 - 42
services/web/frontend/stories/ui/icon-text-button.stories.tsx

@@ -1,42 +0,0 @@
-import IconTextButton from '@/features/ui/components/bootstrap-5/icon-text-button'
-import { Meta } from '@storybook/react'
-
-type Args = React.ComponentProps<typeof IconTextButton>
-
-export const IconText = (args: Args) => {
-  return <IconTextButton {...args} />
-}
-
-const meta: Meta<typeof IconTextButton> = {
-  title: 'Shared / Components / Bootstrap 5 / IconTextButton',
-  component: IconTextButton,
-  args: {
-    children: 'IconTextButton',
-    disabled: false,
-    isLoading: false,
-    leadingIcon: 'add',
-    trailingIcon: 'expand_more',
-  },
-  argTypes: {
-    size: {
-      control: 'radio',
-      options: ['small', 'default', 'large'],
-    },
-    variant: {
-      control: 'radio',
-      options: [
-        'primary',
-        'secondary',
-        'ghost',
-        'danger',
-        'danger-ghost',
-        'premium',
-      ],
-    },
-  },
-  parameters: {
-    bootstrap5: true,
-  },
-}
-
-export default meta