tab.tsx 5.6 KB

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