Browse Source

Merge pull request #16925 from overleaf/rd-dropdown-bootstrap5

Bootstrap-5 Dropdown menu component

GitOrigin-RevId: 8a74f2341eebf953367ab73946d72e8aa7bd3c13
Rebeka Dekany 2 years ago
parent
commit
4a1af0f057

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

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

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

@@ -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" />
+}

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

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

+ 2 - 1
services/web/frontend/js/features/ui/components/types/button-props.ts

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

+ 45 - 0
services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts

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

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

@@ -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 - 0
services/web/frontend/stories/ui/dropdown-menu.stories.tsx

@@ -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 - 0
services/web/frontend/stories/ui/split-button.stories.tsx

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

+ 15 - 0
services/web/frontend/stylesheets/bootstrap-5/foundations/elevation.scss

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

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

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

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

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

+ 106 - 0
services/web/frontend/stylesheets/bootstrap-5/scss/components/dropdown-menu.scss

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

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

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