overlayManager.tsx 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. import { Global, css } from '@emotion/react'
  2. import styled from '@emotion/styled'
  3. import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'
  4. import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
  5. import { transitions } from '@/shared/theme'
  6. import { createId } from '@/utils/createId'
  7. type OverlayManagerContextValue = {
  8. scrollLocked: boolean
  9. setOverlaysSet: React.Dispatch<React.SetStateAction<Set<string>>>
  10. dialogContainerRef: React.RefObject<HTMLDivElement>
  11. contextMenuContainerRef: React.RefObject<HTMLDivElement>
  12. }
  13. const OverlayManagerContext = React.createContext<OverlayManagerContextValue | undefined>(undefined)
  14. OverlayManagerContext.displayName = 'OverlayManagerContext'
  15. export const OverlayManagerProvider: React.FC = ({ children }) => {
  16. const [scrollLocked, setScrollLocked] = useState(false)
  17. const [scrollbarGap, setScrollbarGap] = useState(0)
  18. const [overlaysSet, setOverlaysSet] = useState(new Set<string>())
  19. const dialogContainerRef = useRef<HTMLDivElement>(null)
  20. const contextMenuContainerRef = useRef<HTMLDivElement>(null)
  21. useEffect(() => {
  22. if (overlaysSet.size === 0 && scrollLocked) {
  23. setScrollLocked(false)
  24. setScrollbarGap(0)
  25. enableBodyScroll(document.body)
  26. } else if (overlaysSet.size > 0 && !scrollLocked) {
  27. const scrollbarGap = window.innerWidth - document.documentElement.clientWidth
  28. setScrollLocked(true)
  29. setScrollbarGap(scrollbarGap)
  30. disableBodyScroll(document.body, { reserveScrollBarGap: true })
  31. }
  32. }, [overlaysSet.size, scrollLocked])
  33. return (
  34. <>
  35. <Global styles={[overlayManagerStyles(scrollbarGap), dialogTransitions]} />
  36. <OverlayManagerContext.Provider
  37. value={{
  38. scrollLocked,
  39. setOverlaysSet,
  40. dialogContainerRef,
  41. contextMenuContainerRef,
  42. }}
  43. >
  44. {children}
  45. <PortalContainer ref={dialogContainerRef} />
  46. <PortalContainer ref={contextMenuContainerRef} />
  47. </OverlayManagerContext.Provider>
  48. </>
  49. )
  50. }
  51. const PortalContainer = styled.div`
  52. position: absolute;
  53. top: 0;
  54. left: 0;
  55. right: 0;
  56. `
  57. export const useOverlayManager = () => {
  58. const context = useContext(OverlayManagerContext)
  59. if (!context) {
  60. throw new Error(`useOverlayManager must be used within a OverlayManagerProvider.`)
  61. }
  62. const { setOverlaysSet, dialogContainerRef, contextMenuContainerRef } = context
  63. const overlayId = useRef(createId()).current
  64. const incrementOverlaysOpenCount = useCallback(() => {
  65. setOverlaysSet((prevSet) => new Set(prevSet).add(overlayId))
  66. }, [setOverlaysSet, overlayId])
  67. const decrementOverlaysOpenCount = useCallback(() => {
  68. setOverlaysSet((prevSet) => {
  69. prevSet.delete(overlayId)
  70. return new Set(prevSet)
  71. })
  72. }, [overlayId, setOverlaysSet])
  73. return {
  74. incrementOverlaysOpenCount,
  75. decrementOverlaysOpenCount,
  76. dialogContainerRef,
  77. contextMenuContainerRef,
  78. }
  79. }
  80. const overlayManagerStyles = (scrollbarGap = 0) => css`
  81. :root {
  82. --scrollbar-gap-width: ${scrollbarGap}px;
  83. }
  84. body {
  85. overflow-y: scroll;
  86. }
  87. `
  88. const dialogTransitions = css`
  89. &.${transitions.names.dialog}-enter {
  90. opacity: 0;
  91. transform: scale(0.88);
  92. }
  93. &.${transitions.names.dialog}-enter-active {
  94. opacity: 1;
  95. transform: scale(1);
  96. transition: 150ms cubic-bezier(0.25, 0.01, 0.25, 1);
  97. }
  98. &.${transitions.names.dialog}-exit {
  99. opacity: 1;
  100. transform: scale(1);
  101. }
  102. &.${transitions.names.dialog}-exit-active {
  103. opacity: 0;
  104. transform: scale(0.88);
  105. transition: 100ms cubic-bezier(0.25, 0.01, 0.25, 1);
  106. }
  107. `