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

Merge pull request #17216 from overleaf/rd-bootstrap5-buttons

Bootstrap-5 Button component

GitOrigin-RevId: 1fb13b7ab2b71403b0236f1f85aec7b9545b34f1
Rebeka Dekany 2 лет назад
Родитель
Сommit
e18f0817c6

+ 31 - 17
services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx

@@ -1,5 +1,7 @@
-import { Button as BootstrapButton } from 'react-bootstrap-5'
+import { Button as B5Button, Spinner } from 'react-bootstrap-5'
 import type { ButtonProps } from '@/features/ui/components/types/button-props'
+import classNames from 'classnames'
+import { useTranslation } from 'react-i18next'
 
 const sizeClasses = new Map<ButtonProps['size'], string>([
   ['small', 'btn-sm'],
@@ -7,27 +9,39 @@ const sizeClasses = new Map<ButtonProps['size'], string>([
   ['large', 'btn-lg'],
 ])
 
-// TODO: Display a spinner when `loading` is true
-function Button({
-  variant = 'primary',
-  size = 'default',
-  disabled = false,
-  loading = false,
+export default function Button({
   children,
   className,
+  isLoading = false,
+  size = 'default',
+  ...props
 }: ButtonProps) {
+  const { t } = useTranslation()
+
   const sizeClass = sizeClasses.get(size)
+  const buttonClassName = classNames('d-inline-grid', sizeClass, className, {
+    'button-loading': isLoading,
+  })
+  const loadingSpinnerClassName =
+    size === 'large' ? 'loading-spinner-large' : 'loading-spinner-small'
 
   return (
-    <BootstrapButton
-      className={sizeClass + ' ' + className}
-      variant={variant}
-      disabled={disabled}
-      {...(loading ? { 'data-ol-loading': true } : null)}
-    >
-      {children}
-    </BootstrapButton>
+    <B5Button className={buttonClassName} {...props}>
+      {isLoading && (
+        <span className="spinner-container">
+          <Spinner
+            animation="border"
+            aria-hidden="true"
+            as="span"
+            className={loadingSpinnerClassName}
+            role="status"
+          />
+          <span className="sr-only">{t('loading')}</span>
+        </span>
+      )}
+      <span className="button-content" aria-hidden={isLoading}>
+        {children}
+      </span>
+    </B5Button>
   )
 }
-
-export default Button

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

@@ -0,0 +1,33 @@
+import { useTranslation } from 'react-i18next'
+import MaterialIcon from '@/shared/components/material-icon'
+import Button from './button'
+import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
+import classNames from 'classnames'
+
+export default function IconButton({
+  icon,
+  isLoading = false,
+  size = 'default',
+  ...props
+}: IconButtonProps) {
+  const { t } = useTranslation()
+
+  const iconButtonClassName = `icon-button-${size}`
+  const iconSizeClassName =
+    size === 'large'
+      ? 'leading-trailing-icon-large'
+      : 'leading-trailing-icon-small'
+  const materialIconClassName = classNames(iconSizeClassName, {
+    'button-content-hidden': isLoading,
+  })
+
+  return (
+    <Button className={iconButtonClassName} isLoading={isLoading} {...props}>
+      <MaterialIcon
+        accessibilityLabel={t('add')}
+        className={materialIconClassName}
+        type={icon}
+      />
+    </Button>
+  )
+}

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

@@ -0,0 +1,29 @@
+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'
+      ? 'leading-trailing-icon-large'
+      : 'leading-trailing-icon-small'
+
+  return (
+    <Button size={size} {...props}>
+      {leadingIcon && (
+        <MaterialIcon type={leadingIcon} className={materialIconClassName} />
+      )}
+      {children}
+      {trailingIcon && (
+        <MaterialIcon type={trailingIcon} className={materialIconClassName} />
+      )}
+    </Button>
+  )
+}

+ 10 - 7
services/web/frontend/js/features/ui/components/types/button-props.ts

@@ -1,16 +1,19 @@
-import type { ReactNode } from 'react'
+import type { MouseEventHandler, ReactNode } from 'react'
 
 export type ButtonProps = {
-  variant:
+  children?: ReactNode
+  className?: string
+  disabled?: boolean
+  href?: string
+  isLoading?: boolean
+  onClick?: MouseEventHandler<HTMLButtonElement>
+  size?: 'small' | 'default' | 'large'
+  type?: 'button' | 'reset' | 'submit'
+  variant?:
     | 'primary'
     | 'secondary'
     | 'ghost'
     | 'danger'
     | 'danger-ghost'
     | 'premium'
-  size?: 'small' | 'default' | 'large'
-  disabled?: boolean
-  loading?: boolean
-  children: ReactNode
-  className?: string
 }

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

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

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

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

+ 21 - 2
services/web/frontend/stories/ui/button.stories.tsx

@@ -1,5 +1,5 @@
 import Button from '@/features/ui/components/bootstrap-5/button'
-import type { Meta } from '@storybook/react'
+import { Meta } from '@storybook/react'
 
 type Args = React.ComponentProps<typeof Button>
 
@@ -11,7 +11,26 @@ const meta: Meta<typeof Button> = {
   title: 'Shared / Components / Bootstrap 5 / Button',
   component: Button,
   args: {
-    children: 'A Bootstrap 5 button',
+    children: 'A Bootstrap 5 Button',
+    disabled: false,
+    isLoading: false,
+  },
+  argTypes: {
+    size: {
+      control: 'radio',
+      options: ['small', 'default', 'large'],
+    },
+    variant: {
+      control: 'radio',
+      options: [
+        'primary',
+        'secondary',
+        'ghost',
+        'danger',
+        'danger-ghost',
+        'premium',
+      ],
+    },
   },
   parameters: {
     bootstrap5: true,

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

@@ -0,0 +1,40 @@
+import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
+import type { Meta } from '@storybook/react'
+
+type Args = React.ComponentProps<typeof IconButton>
+
+export const Icon = (args: Args) => {
+  return <IconButton disabled {...args} />
+}
+
+const meta: Meta<typeof IconButton> = {
+  title: 'Shared / Components / Bootstrap 5 / IconButton',
+  component: IconButton,
+  args: {
+    disabled: false,
+    icon: 'add',
+    isLoading: false,
+  },
+  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

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

@@ -0,0 +1,42 @@
+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

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/main-style.scss

@@ -36,5 +36,6 @@ $is-overleaf-light: false;
 @import 'scss/bootstrap-rule-overrides';
 
 // Components
+@import 'scss/components/button';
 @import 'scss/components/dropdown-menu';
 @import 'scss/components/split-button';

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/scss/bootstrap.scss

@@ -30,3 +30,4 @@
 @import 'bootstrap-5/scss/dropdown';
 @import 'bootstrap-5/scss/modal';
 @import 'bootstrap-5/scss/utilities/api';
+@import 'bootstrap-5/scss/spinners';

+ 57 - 0
services/web/frontend/stylesheets/bootstrap-5/scss/components/button.scss

@@ -0,0 +1,57 @@
+.button-loading {
+  align-items: center;
+  display: inline-grid;
+  grid-template-areas: 'container'; // Define a single grid area
+}
+
+.button-loading > * {
+  grid-area: container; // Position all the direct children within the single grid area
+}
+
+.button-loading .spinner-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  .loading-spinner-small {
+    border-width: 0.2em;
+    height: 20px;
+    width: 20px;
+  }
+  .loading-spinner-large {
+    border-width: 0.2em;
+    height: 24px;
+    width: 24px;
+  }
+}
+
+// Hide the text when the spinner is visible
+.button-loading > [aria-hidden='true'] {
+  visibility: hidden;
+}
+
+.button-content {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--spacing-04); // Add gap between text and icons
+
+  .leading-trailing-icon-small {
+    font-size: 20px;
+  }
+
+  .leading-trailing-icon-large {
+    font-size: 24px;
+  }
+}
+
+.icon-button-small {
+  padding: var(--spacing-01);
+}
+
+.icon-button-default {
+  padding: var(--spacing-04);
+}
+
+.icon-button-large {
+  padding: var(--spacing-05);
+}