tab.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import {forwardRef, useCallback} from 'react';
  2. import {Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {useTab} from '@react-aria/tabs';
  5. import {useObjectRef} from '@react-aria/utils';
  6. import {TabListState} from '@react-stately/tabs';
  7. import {Node, Orientation} from '@react-types/shared';
  8. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  9. import Link from 'sentry/components/links/link';
  10. import {space} from 'sentry/styles/space';
  11. import {tabsShouldForwardProp} from './utils';
  12. interface TabProps {
  13. item: Node<any>;
  14. orientation: Orientation;
  15. /**
  16. * Whether this tab is overflowing the TabList container. If so, the tab
  17. * needs to be visually hidden. Users can instead select it via an overflow
  18. * menu.
  19. */
  20. overflowing: boolean;
  21. state: TabListState<any>;
  22. }
  23. /**
  24. * Stops event propagation if the command/ctrl/shift key is pressed, in effect
  25. * preventing any state change. This is useful because when a user
  26. * command/ctrl/shift-clicks on a tab link, the intention is to view the tab
  27. * in a new browser tab/window, not to update the current view.
  28. */
  29. function handleLinkClick(e: React.PointerEvent<HTMLAnchorElement>) {
  30. if (e.metaKey || e.ctrlKey || e.shiftKey) {
  31. e.stopPropagation();
  32. }
  33. }
  34. /**
  35. * Renders a single tab item. This should not be imported directly into any
  36. * page/view – it's only meant to be used by <TabsList />. See the correct
  37. * usage in tabs.stories.js
  38. */
  39. function BaseTab(
  40. {item, state, orientation, overflowing}: TabProps,
  41. forwardedRef: React.ForwardedRef<HTMLLIElement>
  42. ) {
  43. const ref = useObjectRef(forwardedRef);
  44. const {
  45. key,
  46. rendered,
  47. props: {to, hidden},
  48. } = item;
  49. const {tabProps, isSelected, isDisabled} = useTab(
  50. {key, isDisabled: hidden},
  51. state,
  52. ref
  53. );
  54. const InnerWrap = useCallback(
  55. ({children}) =>
  56. to ? (
  57. <TabLink
  58. to={to}
  59. onMouseDown={handleLinkClick}
  60. onPointerDown={handleLinkClick}
  61. orientation={orientation}
  62. tabIndex={-1}
  63. >
  64. {children}
  65. </TabLink>
  66. ) : (
  67. <TabInnerWrap orientation={orientation}>{children}</TabInnerWrap>
  68. ),
  69. [to, orientation]
  70. );
  71. return (
  72. <TabWrap
  73. {...tabProps}
  74. hidden={hidden}
  75. disabled={isDisabled}
  76. selected={isSelected}
  77. overflowing={overflowing}
  78. ref={ref}
  79. >
  80. <InnerWrap>
  81. <StyledInteractionStateLayer
  82. orientation={orientation}
  83. higherOpacity={isSelected}
  84. />
  85. <FocusLayer orientation={orientation} />
  86. {rendered}
  87. <TabSelectionIndicator orientation={orientation} selected={isSelected} />
  88. </InnerWrap>
  89. </TabWrap>
  90. );
  91. }
  92. export const Tab = forwardRef(BaseTab);
  93. const TabWrap = styled('li', {shouldForwardProp: tabsShouldForwardProp})<{
  94. disabled: boolean;
  95. overflowing: boolean;
  96. selected: boolean;
  97. }>`
  98. color: ${p => (p.selected ? p.theme.activeText : p.theme.textColor)};
  99. white-space: nowrap;
  100. cursor: pointer;
  101. &:hover {
  102. color: ${p => (p.selected ? p.theme.activeText : p.theme.headingColor)};
  103. }
  104. &:focus {
  105. outline: none;
  106. }
  107. ${p =>
  108. p.disabled &&
  109. `
  110. &, &:hover {
  111. color: ${p.theme.subText};
  112. pointer-events: none;
  113. }
  114. `}
  115. ${p =>
  116. p.overflowing &&
  117. `
  118. opacity: 0;
  119. pointer-events: none;
  120. `}
  121. `;
  122. const innerWrapStyles = ({
  123. theme,
  124. orientation,
  125. }: {
  126. orientation: Orientation;
  127. theme: Theme;
  128. }) => `
  129. display: flex;
  130. align-items: center;
  131. position: relative;
  132. height: calc(
  133. ${theme.form.sm.height}px +
  134. ${orientation === 'horizontal' ? space(0.75) : '0px'}
  135. );
  136. border-radius: ${theme.borderRadius};
  137. transform: translateY(1px);
  138. ${
  139. orientation === 'horizontal'
  140. ? `
  141. /* Extra padding + negative margin trick, to expand click area */
  142. padding: ${space(0.75)} ${space(1)} ${space(1.5)};
  143. margin-left: -${space(1)};
  144. margin-right: -${space(1)};
  145. `
  146. : `padding: ${space(0.75)} ${space(2)};`
  147. };
  148. `;
  149. const TabLink = styled(Link)<{orientation: Orientation}>`
  150. ${innerWrapStyles}
  151. &,
  152. &:hover {
  153. color: inherit;
  154. }
  155. `;
  156. const TabInnerWrap = styled('span')<{orientation: Orientation}>`
  157. ${innerWrapStyles}
  158. `;
  159. const StyledInteractionStateLayer = styled(InteractionStateLayer)<{
  160. orientation: Orientation;
  161. }>`
  162. position: absolute;
  163. width: auto;
  164. height: auto;
  165. transform: none;
  166. left: 0;
  167. right: 0;
  168. top: 0;
  169. bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)};
  170. `;
  171. const FocusLayer = styled('div')<{orientation: Orientation}>`
  172. position: absolute;
  173. left: 0;
  174. right: 0;
  175. top: 0;
  176. bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)};
  177. pointer-events: none;
  178. border-radius: inherit;
  179. z-index: 0;
  180. transition: box-shadow 0.1s ease-out;
  181. li.focus-visible & {
  182. box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px,
  183. inset ${p => p.theme.focusBorder} 0 0 0 1px;
  184. }
  185. `;
  186. const TabSelectionIndicator = styled('div')<{
  187. orientation: Orientation;
  188. selected: boolean;
  189. }>`
  190. position: absolute;
  191. border-radius: 2px;
  192. pointer-events: none;
  193. background: ${p => (p.selected ? p.theme.active : 'transparent')};
  194. transition: background 0.1s ease-out;
  195. li[aria-disabled='true'] & {
  196. background: ${p => (p.selected ? p.theme.subText : 'transparent')};
  197. }
  198. ${p =>
  199. p.orientation === 'horizontal'
  200. ? `
  201. width: calc(100% - ${space(2)});
  202. height: 3px;
  203. bottom: 0;
  204. left: 50%;
  205. transform: translateX(-50%);
  206. `
  207. : `
  208. width: 3px;
  209. height: 50%;
  210. left: 0;
  211. top: 50%;
  212. transform: translateY(-50%);
  213. `};
  214. `;