detailPanel.tsx 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import {useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {AnimatePresence} from 'framer-motion';
  4. import {Button} from 'sentry/components/button';
  5. import SlideOverPanel from 'sentry/components/slideOverPanel';
  6. import {IconPanel} from 'sentry/icons';
  7. import {IconClose} from 'sentry/icons/iconClose';
  8. import {t} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import localStorage from 'sentry/utils/localStorage';
  11. import useKeyPress from 'sentry/utils/useKeyPress';
  12. import useOnClickOutside from 'sentry/utils/useOnClickOutside';
  13. type DetailProps = {
  14. children: React.ReactNode;
  15. detailKey?: string;
  16. onClose?: () => void;
  17. onOpen?: () => void;
  18. skipCloseOnOutsideClick?: boolean;
  19. startingPositionOnLoad?: 'right' | 'bottom';
  20. };
  21. type DetailState = {
  22. collapsed: boolean;
  23. };
  24. const SLIDEOUT_STORAGE_KEY = 'starfish-panel-slideout-direction';
  25. function isValidSlidePosition(value: string | null): value is 'right' | 'bottom' {
  26. return value === 'right' || value === 'bottom';
  27. }
  28. export default function Detail({
  29. children,
  30. detailKey,
  31. onClose,
  32. onOpen,
  33. startingPositionOnLoad,
  34. skipCloseOnOutsideClick = false,
  35. }: DetailProps) {
  36. const localStorageValue = localStorage.getItem(SLIDEOUT_STORAGE_KEY);
  37. const storedSlideOutPosition = isValidSlidePosition(localStorageValue)
  38. ? localStorageValue
  39. : null;
  40. const [state, setState] = useState<DetailState>({collapsed: true});
  41. const [slidePosition, setSlidePosition] = useState<'right' | 'bottom'>(
  42. startingPositionOnLoad ?? storedSlideOutPosition ?? 'right'
  43. );
  44. const escapeKeyPressed = useKeyPress('Escape');
  45. // Any time the key prop changes (due to user interaction), we want to open the panel
  46. useEffect(() => {
  47. if (detailKey) {
  48. setState({collapsed: false});
  49. } else {
  50. setState({collapsed: true});
  51. }
  52. }, [detailKey]);
  53. const panelRef = useRef<HTMLDivElement>(null);
  54. useOnClickOutside(panelRef, () => {
  55. if (!state.collapsed && !skipCloseOnOutsideClick) {
  56. onClose?.();
  57. setState({collapsed: true});
  58. }
  59. });
  60. useEffect(() => {
  61. if (escapeKeyPressed) {
  62. if (!state.collapsed) {
  63. onClose?.();
  64. setState({collapsed: true});
  65. }
  66. }
  67. // eslint-disable-next-line react-hooks/exhaustive-deps
  68. }, [escapeKeyPressed]);
  69. const handleDocking = (position: 'right' | 'bottom') => {
  70. setSlidePosition(position);
  71. localStorage.setItem(SLIDEOUT_STORAGE_KEY, position);
  72. };
  73. return (
  74. <AnimatePresence>
  75. <SlideOverPanel
  76. slidePosition={slidePosition}
  77. collapsed={state.collapsed}
  78. ref={panelRef}
  79. onOpen={onOpen}
  80. >
  81. <CloseButtonWrapper>
  82. <PanelButton
  83. priority="link"
  84. size="zero"
  85. borderless
  86. aria-label={t('Dock to the bottom')}
  87. disabled={slidePosition === 'bottom'}
  88. icon={<IconPanel size="sm" direction="down" />}
  89. onClick={() => handleDocking('bottom')}
  90. />
  91. <PanelButton
  92. priority="link"
  93. size="zero"
  94. borderless
  95. aria-label={t('Dock to the right')}
  96. disabled={slidePosition === 'right'}
  97. icon={<IconPanel size="sm" direction="right" />}
  98. onClick={() => handleDocking('right')}
  99. />
  100. <CloseButton
  101. priority="link"
  102. size="zero"
  103. borderless
  104. aria-label={t('Close Details')}
  105. icon={<IconClose size="sm" />}
  106. onClick={() => {
  107. setState({collapsed: true});
  108. onClose?.();
  109. }}
  110. />
  111. </CloseButtonWrapper>
  112. <DetailWrapper>{children}</DetailWrapper>
  113. </SlideOverPanel>
  114. </AnimatePresence>
  115. );
  116. }
  117. const CloseButton = styled(Button)`
  118. color: ${p => p.theme.gray300};
  119. &:hover {
  120. color: ${p => p.theme.gray400};
  121. }
  122. z-index: 100;
  123. `;
  124. const PanelButton = styled(Button)`
  125. color: ${p => p.theme.gray300};
  126. &:hover {
  127. color: ${p => p.theme.gray400};
  128. }
  129. z-index: 100;
  130. `;
  131. const CloseButtonWrapper = styled('div')`
  132. justify-content: flex-end;
  133. display: flex;
  134. padding: ${space(2)};
  135. `;
  136. const DetailWrapper = styled('div')`
  137. padding: 0 ${space(4)} ${space(4)};
  138. `;