dropdown-menu.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import React, {
  2. createContext,
  3. forwardRef,
  4. useCallback,
  5. useContext,
  6. useLayoutEffect,
  7. useMemo,
  8. useState,
  9. } from 'react'
  10. import {
  11. Dropdown as BS5Dropdown,
  12. DropdownToggle as BS5DropdownToggle,
  13. DropdownMenu as BS5DropdownMenu,
  14. DropdownItem as BS5DropdownItem,
  15. DropdownDivider as BS5DropdownDivider,
  16. DropdownHeader as BS5DropdownHeader,
  17. Button as BS5Button,
  18. } from 'react-bootstrap'
  19. import type {
  20. DropdownProps,
  21. DropdownItemProps,
  22. DropdownToggleProps,
  23. DropdownMenuProps,
  24. DropdownDividerProps,
  25. DropdownHeaderProps,
  26. } from '@/shared/components/types/dropdown-menu-props'
  27. import MaterialIcon from '@/shared/components/material-icon'
  28. import { fixedForwardRef } from '@/utils/react'
  29. import classnames from 'classnames'
  30. // Lets DropdownToggle register its presence with the parent Dropdown so that
  31. // DropdownMenu can know whether a toggle exists (and therefore whether Popper
  32. // will run to position the menu).
  33. type DropdownInternalContextValue = {
  34. registerToggle: () => () => void
  35. hasToggle: boolean
  36. }
  37. const DropdownInternalContext =
  38. createContext<DropdownInternalContextValue | null>(null)
  39. export function Dropdown({ children, ...props }: DropdownProps) {
  40. const [toggleCount, setToggleCount] = useState(0)
  41. const registerToggle = useCallback(() => {
  42. setToggleCount(c => c + 1)
  43. return () => setToggleCount(c => c - 1)
  44. }, [])
  45. const value = useMemo(
  46. () => ({ registerToggle, hasToggle: toggleCount > 0 }),
  47. [registerToggle, toggleCount]
  48. )
  49. return (
  50. <DropdownInternalContext.Provider value={value}>
  51. <BS5Dropdown {...props}>{children}</BS5Dropdown>
  52. </DropdownInternalContext.Provider>
  53. )
  54. }
  55. function DropdownItem(
  56. {
  57. active,
  58. children,
  59. className,
  60. description,
  61. leadingIcon,
  62. trailingIcon,
  63. ...props
  64. }: DropdownItemProps,
  65. ref: React.ForwardedRef<typeof BS5DropdownItem>
  66. ) {
  67. let leadingIconComponent = null
  68. if (leadingIcon) {
  69. if (typeof leadingIcon === 'string') {
  70. leadingIconComponent = (
  71. <MaterialIcon
  72. className="dropdown-item-leading-icon"
  73. type={leadingIcon}
  74. />
  75. )
  76. } else {
  77. leadingIconComponent = (
  78. <span className="dropdown-item-leading-icon" aria-hidden="true">
  79. {leadingIcon}
  80. </span>
  81. )
  82. }
  83. }
  84. let trailingIconComponent = null
  85. if (trailingIcon) {
  86. if (typeof trailingIcon === 'string') {
  87. const trailingIconType = active ? 'check' : trailingIcon
  88. trailingIconComponent = (
  89. <MaterialIcon
  90. className="dropdown-item-trailing-icon"
  91. type={trailingIconType}
  92. />
  93. )
  94. } else {
  95. trailingIconComponent = (
  96. <span className="dropdown-item-trailing-icon" aria-hidden="true">
  97. {trailingIcon}
  98. </span>
  99. )
  100. }
  101. }
  102. return (
  103. <BS5DropdownItem
  104. active={active}
  105. className={className}
  106. role="menuitem"
  107. {...props}
  108. ref={ref}
  109. >
  110. {leadingIconComponent}
  111. {description ? (
  112. <span className="dropdown-item-description-container">
  113. {children}
  114. <span className="dropdown-item-description">{description}</span>
  115. </span>
  116. ) : (
  117. children
  118. )}
  119. {trailingIconComponent}
  120. </BS5DropdownItem>
  121. )
  122. }
  123. function EmptyLeadingIcon() {
  124. return <span className="dropdown-item-leading-icon-empty" />
  125. }
  126. const ForwardReferredDropdownItem = fixedForwardRef(DropdownItem, {
  127. EmptyLeadingIcon,
  128. })
  129. export { ForwardReferredDropdownItem as DropdownItem }
  130. export const DropdownToggleCustom = forwardRef<
  131. HTMLButtonElement,
  132. React.ComponentProps<typeof BS5Button>
  133. >(({ children, className, ...props }, ref) => (
  134. <BS5Button
  135. ref={ref}
  136. className={classnames('custom-toggle', className)}
  137. {...props}
  138. >
  139. {children}
  140. <MaterialIcon type="expand_more" />
  141. </BS5Button>
  142. ))
  143. DropdownToggleCustom.displayName = 'DropdownToggleCustom'
  144. export const DropdownToggle = forwardRef<
  145. typeof BS5DropdownToggle,
  146. DropdownToggleProps
  147. >((props, ref) => {
  148. const registerToggle = useContext(DropdownInternalContext)?.registerToggle
  149. useLayoutEffect(() => registerToggle?.(), [registerToggle])
  150. return <BS5DropdownToggle {...props} ref={ref} />
  151. })
  152. DropdownToggle.displayName = 'DropdownToggle'
  153. export const DropdownMenu = forwardRef<
  154. typeof BS5DropdownMenu,
  155. DropdownMenuProps
  156. >(({ as = 'ul', className, ...props }, ref) => {
  157. const context = useContext(DropdownInternalContext)
  158. return (
  159. <BS5DropdownMenu
  160. as={as}
  161. role="menu"
  162. className={classnames(className, {
  163. 'dropdown-menu-popper': !!context?.hasToggle,
  164. })}
  165. {...props}
  166. ref={ref}
  167. />
  168. )
  169. })
  170. DropdownMenu.displayName = 'DropdownMenu'
  171. export function DropdownDivider({ as = 'li', ...props }: DropdownDividerProps) {
  172. return <BS5DropdownDivider as={as} {...props} />
  173. }
  174. export function DropdownHeader({ as = 'li', ...props }: DropdownHeaderProps) {
  175. return <BS5DropdownHeader as={as} {...props} />
  176. }