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

Bootstrap-5 Button component

GitOrigin-RevId: 1fb13b7ab2b71403b0236f1f85aec7b9545b34f1
This commit is contained in:
Rebeka Dekany 2024-03-06 12:31:25 +01:00 committed by Copybot
parent 5415aafaf8
commit e18f0817c6
12 changed files with 277 additions and 26 deletions

View file

@ -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

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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
}

View file

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

View file

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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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';

View file

@ -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';

View file

@ -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);
}