mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-11 17:15:35 +00:00
Merge pull request #16925 from overleaf/rd-dropdown-bootstrap5
Bootstrap-5 Dropdown menu component GitOrigin-RevId: 8a74f2341eebf953367ab73946d72e8aa7bd3c13
This commit is contained in:
parent
3d4f3d3d73
commit
4a1af0f057
13 changed files with 494 additions and 4 deletions
services/web/frontend
js/features/ui/components
bootstrap-5
types
stories/ui
stylesheets/bootstrap-5
|
@ -14,13 +14,14 @@ function Button({
|
|||
disabled = false,
|
||||
loading = false,
|
||||
children,
|
||||
className,
|
||||
}: ButtonProps) {
|
||||
const sizeClass = sizeClasses.get(size)
|
||||
|
||||
return (
|
||||
<BootstrapButton
|
||||
className={sizeClass + ' ' + className}
|
||||
variant={variant}
|
||||
className={sizeClass}
|
||||
disabled={disabled}
|
||||
{...(loading ? { 'data-ol-loading': true } : null)}
|
||||
>
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
Dropdown as B5Dropdown,
|
||||
DropdownToggle as B5DropdownToggle,
|
||||
DropdownMenu as B5DropdownMenu,
|
||||
DropdownItem as B5DropdownItem,
|
||||
DropdownDivider as B5DropdownDivider,
|
||||
} from 'react-bootstrap-5'
|
||||
import type {
|
||||
DropdownProps,
|
||||
DropdownItemProps,
|
||||
DropdownToggleProps,
|
||||
DropdownMenuProps,
|
||||
} from '@/features/ui/components/types/dropdown-menu-props'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
export function Dropdown({ ...props }: DropdownProps) {
|
||||
return <B5Dropdown {...props} />
|
||||
}
|
||||
|
||||
export function DropdownItem({
|
||||
active,
|
||||
children,
|
||||
description,
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
...props
|
||||
}: DropdownItemProps) {
|
||||
const trailingIconType = active ? 'check' : trailingIcon
|
||||
return (
|
||||
<li>
|
||||
<B5DropdownItem
|
||||
active={active}
|
||||
className={description ? 'dropdown-item-description-container' : ''}
|
||||
role="menuitem"
|
||||
{...props}
|
||||
>
|
||||
{leadingIcon && (
|
||||
<MaterialIcon
|
||||
className="dropdown-item-leading-icon"
|
||||
type={leadingIcon}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{trailingIconType && (
|
||||
<MaterialIcon
|
||||
className="dropdown-item-trailing-icon"
|
||||
type={trailingIconType}
|
||||
/>
|
||||
)}
|
||||
{description && (
|
||||
<span className="dropdown-item-description">{description}</span>
|
||||
)}
|
||||
</B5DropdownItem>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownToggle({ ...props }: DropdownToggleProps) {
|
||||
return <B5DropdownToggle {...props} />
|
||||
}
|
||||
|
||||
export function DropdownMenu({ as = 'ul', ...props }: DropdownMenuProps) {
|
||||
return <B5DropdownMenu as={as} role="menubar" {...props} />
|
||||
}
|
||||
|
||||
export function DropdownDivider() {
|
||||
return <B5DropdownDivider aria-hidden="true" />
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import classNames from 'classnames'
|
||||
import Button from './button'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from './dropdown-menu'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import type { SplitButtonProps } from '@/features/ui/components/types/split-button-props'
|
||||
|
||||
export function SplitButton({
|
||||
align,
|
||||
id,
|
||||
items,
|
||||
text,
|
||||
variant,
|
||||
...props
|
||||
}: SplitButtonProps) {
|
||||
const buttonClassName = classNames('split-button')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown align={align}>
|
||||
<Button className={buttonClassName} variant={variant} {...props}>
|
||||
<span className="split-button-content">{text}</span>
|
||||
</Button>
|
||||
<DropdownToggle
|
||||
bsPrefix="dropdown-button-toggle"
|
||||
id={id}
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
<MaterialIcon className="split-button-caret" type="expand_more" />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
{items.map((item, index) => (
|
||||
<DropdownItem key={index} eventKey={item.eventKey}>
|
||||
{item.label}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export type ButtonProps = {
|
||||
variant?:
|
||||
variant:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'ghost'
|
||||
|
@ -12,4 +12,5 @@ export type ButtonProps = {
|
|||
disabled?: boolean
|
||||
loading?: boolean
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import type { ElementType, ReactNode, PropsWithChildren } from 'react'
|
||||
import type { SplitButtonVariants } from './split-button-props'
|
||||
|
||||
export type DropdownProps = {
|
||||
align?:
|
||||
| 'start'
|
||||
| 'end'
|
||||
| { sm: 'start' | 'end' }
|
||||
| { md: 'start' | 'end' }
|
||||
| { lg: 'start' | 'end' }
|
||||
| { xl: 'start' | 'end' }
|
||||
| { xxl: 'start' | 'end' }
|
||||
as?: ElementType
|
||||
children: ReactNode
|
||||
onSelect?: (eventKey: any, event: object) => any
|
||||
onToggle?: (show: boolean) => void
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
export type DropdownItemProps = PropsWithChildren<{
|
||||
active?: boolean
|
||||
as?: ElementType
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
eventKey: string | number
|
||||
href?: string
|
||||
leadingIcon?: string
|
||||
onClick?: () => void
|
||||
trailingIcon?: string
|
||||
variant?: 'default' | 'danger'
|
||||
}>
|
||||
|
||||
export type DropdownToggleProps = PropsWithChildren<{
|
||||
bsPrefix?: string
|
||||
disabled?: boolean
|
||||
split?: boolean
|
||||
id: string // necessary for assistive technologies
|
||||
variant: SplitButtonVariants
|
||||
}>
|
||||
|
||||
export type DropdownMenuProps = PropsWithChildren<{
|
||||
as?: ElementType
|
||||
disabled?: boolean
|
||||
show?: boolean
|
||||
}>
|
|
@ -0,0 +1,28 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
import type {
|
||||
DropdownItemProps,
|
||||
DropdownProps,
|
||||
DropdownToggleProps,
|
||||
} from './dropdown-menu-props'
|
||||
import type { ButtonProps } from './button-props'
|
||||
|
||||
type SplitButtonItemProps = Pick<
|
||||
DropdownItemProps,
|
||||
'eventKey' | 'leadingIcon'
|
||||
> & {
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
export type SplitButtonVariants = Extract<
|
||||
ButtonProps['variant'],
|
||||
'primary' | 'secondary' | 'danger'
|
||||
>
|
||||
|
||||
export type SplitButtonProps = PropsWithChildren<{
|
||||
align?: DropdownProps['align']
|
||||
disabled?: boolean
|
||||
id: DropdownToggleProps['id']
|
||||
items: SplitButtonItemProps[]
|
||||
text: string
|
||||
variant: SplitButtonVariants
|
||||
}>
|
132
services/web/frontend/stories/ui/dropdown-menu.stories.tsx
Normal file
132
services/web/frontend/stories/ui/dropdown-menu.stories.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
import {
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
DropdownDivider,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import type { Meta } from '@storybook/react'
|
||||
|
||||
type Args = React.ComponentProps<typeof DropdownMenu>
|
||||
|
||||
export const Default = (args: Args) => {
|
||||
return (
|
||||
<DropdownMenu show>
|
||||
<DropdownItem eventKey="1" href="#/action-1">
|
||||
Example
|
||||
</DropdownItem>
|
||||
<DropdownItem eventKey="2" href="#/action-2">
|
||||
Example
|
||||
</DropdownItem>
|
||||
<DropdownDivider />
|
||||
<DropdownItem eventKey="3" disabled={args.disabled} href="#/action-3">
|
||||
Example
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const Active = (args: Args) => {
|
||||
return (
|
||||
<DropdownMenu show>
|
||||
<DropdownItem eventKey="1" href="#/action-1">
|
||||
Example
|
||||
</DropdownItem>
|
||||
<DropdownItem eventKey="2" active href="#/action-2" trailingIcon="check">
|
||||
Example
|
||||
</DropdownItem>
|
||||
<DropdownDivider />
|
||||
<DropdownItem eventKey="3" disabled={args.disabled} href="#/action-3">
|
||||
Example
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const Danger = (args: Args) => {
|
||||
return (
|
||||
<DropdownMenu show>
|
||||
<DropdownItem eventKey="1" disabled={args.disabled} href="#/action-1">
|
||||
Example
|
||||
</DropdownItem>
|
||||
<DropdownItem eventKey="2" href="#/action-2">
|
||||
Example
|
||||
</DropdownItem>
|
||||
<DropdownDivider />
|
||||
<DropdownItem eventKey="3" href="#/action-3" variant="danger">
|
||||
Example
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const Description = (args: Args) => {
|
||||
return (
|
||||
<DropdownMenu show>
|
||||
<DropdownItem
|
||||
disabled={args.disabled}
|
||||
eventKey="1"
|
||||
href="#/action-1"
|
||||
description="Description of the menu"
|
||||
>
|
||||
Example
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
active
|
||||
eventKey="2"
|
||||
href="#/action-2"
|
||||
description="Description of the menu"
|
||||
trailingIcon="check"
|
||||
>
|
||||
Example
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const Icon = (args: Args) => {
|
||||
return (
|
||||
<DropdownMenu show>
|
||||
<DropdownItem
|
||||
disabled={args.disabled}
|
||||
eventKey="1"
|
||||
href="#/action-1"
|
||||
leadingIcon="view_column_2"
|
||||
>
|
||||
Editor & PDF
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
active
|
||||
eventKey="2"
|
||||
href="#/action-2"
|
||||
leadingIcon="terminal"
|
||||
>
|
||||
Editor only
|
||||
</DropdownItem>
|
||||
<DropdownItem eventKey="2" href="#/action-2" leadingIcon="picture_as_pdf">
|
||||
PDF only
|
||||
</DropdownItem>
|
||||
<DropdownItem eventKey="2" href="#/action-2" leadingIcon="select_window">
|
||||
PDF in separate tab
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const meta: Meta<typeof DropdownMenu> = {
|
||||
title: 'Shared / Components / Bootstrap 5 / DropdownMenu',
|
||||
component: DropdownMenu,
|
||||
argTypes: {
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
show: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
bootstrap5: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
34
services/web/frontend/stories/ui/split-button.stories.tsx
Normal file
34
services/web/frontend/stories/ui/split-button.stories.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { SplitButton } from '@/features/ui/components/bootstrap-5/split-button'
|
||||
import type { Meta } from '@storybook/react'
|
||||
|
||||
type Args = React.ComponentProps<typeof SplitButton>
|
||||
|
||||
export const Dropdown = (args: Args) => {
|
||||
return <SplitButton {...args} />
|
||||
}
|
||||
const meta: Meta<typeof SplitButton> = {
|
||||
title: 'Shared/Components/Bootstrap 5/SplitButton',
|
||||
component: SplitButton,
|
||||
args: {
|
||||
align: { sm: 'start' },
|
||||
id: 'split-button',
|
||||
items: [
|
||||
{ eventKey: '1', label: 'Action 1' },
|
||||
{ eventKey: '2', label: 'Action 2' },
|
||||
{ eventKey: '3', label: 'Action 3' },
|
||||
],
|
||||
text: 'Split Button',
|
||||
},
|
||||
argTypes: {
|
||||
id: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
bootstrap5: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
|
@ -0,0 +1,15 @@
|
|||
// Cards
|
||||
@mixin shadow-sm {
|
||||
box-shadow: 0px 2px 8px rgba(30, 37, 48, 0.08);
|
||||
}
|
||||
|
||||
// Tooltips, Callouts, Dropdowns, etc.
|
||||
@mixin shadow-md {
|
||||
box-shadow: 0px 4px 24px rgba(30, 37, 48, 0.12), 0px 1px 4px rgba(30, 37, 48, 0.08);
|
||||
}
|
||||
|
||||
// Modals, drawers
|
||||
@mixin shadow-lg {
|
||||
box-shadow: 0px 8px 24px rgba(30, 37, 48, 0.16), 0px 4px 8px rgba(30, 37, 48, 0.16);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ $is-overleaf-light: false;
|
|||
@import 'foundations/spacing';
|
||||
@import 'foundations/typography';
|
||||
@import 'foundations/border-radius';
|
||||
@import 'foundations/elevation';
|
||||
|
||||
// Boostrap-related
|
||||
|
||||
|
@ -33,3 +34,7 @@ $is-overleaf-light: false;
|
|||
// variables in the usual CSS way, and can also refer to (but not override)
|
||||
// Bootstrap Sass variables.
|
||||
@import 'scss/bootstrap-rule-overrides';
|
||||
|
||||
// Components
|
||||
@import 'scss/components/dropdown-menu';
|
||||
@import 'scss/components/split-button';
|
||||
|
|
|
@ -27,7 +27,6 @@
|
|||
@import 'bootstrap-5/scss/grid';
|
||||
@import 'bootstrap-5/scss/helpers';
|
||||
@import 'bootstrap-5/scss/buttons';
|
||||
@import 'bootstrap-5/scss/dropdown';
|
||||
@import 'bootstrap-5/scss/modal';
|
||||
|
||||
// Include utilities API last to generate classes based on the Sass map in `_utilities.scss`
|
||||
@import 'bootstrap-5/scss/utilities/api';
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
.dropdown {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
@include shadow-sm;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-02);
|
||||
transform: none;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@include body-sm;
|
||||
border-radius: var(--border-radius-base);
|
||||
color: var(--neutral-90);
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
justify-content: start;
|
||||
min-height: 44px; // a minimum height of 44px to be accessible for touch screens
|
||||
padding: var(--spacing-05) var(--spacing-04);
|
||||
position: relative;
|
||||
|
||||
&:hover:not(.active) {
|
||||
background-color: var(--bg-light-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--bg-accent-03);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&[variant="danger"] {
|
||||
color: var(--content-danger);
|
||||
|
||||
&:hover:not(.active) {
|
||||
background-color: var(--bg-danger-03);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--bg-accent-03);
|
||||
color: var(--green-70);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
border-radius: 1px;
|
||||
background-color: var(--border-divider);
|
||||
margin: 2px 6px;
|
||||
}
|
||||
|
||||
.dropdown-item-description {
|
||||
@include body-xs;
|
||||
color: var(--content-secondary);
|
||||
margin-top: var(--spacing-01);
|
||||
}
|
||||
|
||||
.dropdown-item-description-container {
|
||||
grid-auto-flow: row;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.dropdown-item-trailing-icon {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.dropdown-item-leading-icon {
|
||||
padding-right: var(--spacing-04);
|
||||
}
|
||||
|
||||
// description text should look disabled when the dropdown item is disabled
|
||||
.dropdown-item.disabled .dropdown-item-description,
|
||||
.dropdown-item[aria-disabled="true"] .dropdown-item-description {
|
||||
background-color: transparent;
|
||||
color: var(--content-disabled);
|
||||
}
|
||||
|
||||
// override disabled styles when the state is active
|
||||
.dropdown-item.active .dropdown-item-description {
|
||||
background-color: initial;
|
||||
color: var(--green-70);
|
||||
}
|
||||
|
||||
.dropdown-button-toggle {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
padding-right: var(--spacing-05);
|
||||
padding-left: var(--spacing-05);
|
||||
|
||||
&.btn-primary, &.btn-danger {
|
||||
border-left: 1px solid rgb($neutral-90, 0.16);
|
||||
}
|
||||
&.btn-secondary {
|
||||
border-left: 1px solid var(--neutral-60);
|
||||
}
|
||||
|
||||
&[disabled], &[aria-disabled="true"] {
|
||||
border-left: 1px solid var(--neutral-10);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.split-button {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.split-button-caret {
|
||||
vertical-align: middle;
|
||||
}
|
Loading…
Add table
Reference in a new issue