tab.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import {forwardRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useTab} from '@react-aria/tabs';
  4. import {mergeProps, useObjectRef} from '@react-aria/utils';
  5. import {TabListState} from '@react-stately/tabs';
  6. import {Node, Orientation} from '@react-types/shared';
  7. import space from 'sentry/styles/space';
  8. import {tabsShouldForwardProp} from './utils';
  9. interface TabProps {
  10. item: Node<any>;
  11. orientation: Orientation;
  12. /**
  13. * Whether this tab is overflowing the TabList container. If so, the tab
  14. * needs to be visually hidden. Users can instead select it via an overflow
  15. * menu.
  16. */
  17. overflowing: boolean;
  18. state: TabListState<any>;
  19. }
  20. /**
  21. * Renders a single tab item. This should not be imported directly into any
  22. * page/view – it's only meant to be used by <TabsList />. See the correct
  23. * usage in tabs.stories.js
  24. */
  25. function BaseTab(
  26. {item, state, orientation, overflowing}: TabProps,
  27. forwardedRef: React.ForwardedRef<HTMLLIElement>
  28. ) {
  29. const ref = useObjectRef(forwardedRef);
  30. const {key, rendered} = item;
  31. const {tabProps, isSelected, isDisabled} = useTab({key}, state, ref);
  32. return (
  33. <TabWrap
  34. {...mergeProps(tabProps)}
  35. disabled={isDisabled}
  36. selected={isSelected}
  37. overflowing={overflowing}
  38. orientation={orientation}
  39. ref={ref}
  40. >
  41. <HoverLayer orientation={orientation} />
  42. <FocusLayer orientation={orientation} />
  43. {rendered}
  44. <TabSelectionIndicator orientation={orientation} selected={isSelected} />
  45. </TabWrap>
  46. );
  47. }
  48. export const Tab = forwardRef(BaseTab);
  49. const TabWrap = styled('li', {shouldForwardProp: tabsShouldForwardProp})<{
  50. disabled: boolean;
  51. orientation: Orientation;
  52. overflowing: boolean;
  53. selected: boolean;
  54. }>`
  55. display: flex;
  56. align-items: center;
  57. position: relative;
  58. height: calc(
  59. ${p => p.theme.form.sm.height}px +
  60. ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)}
  61. );
  62. border-radius: ${p => p.theme.borderRadius};
  63. transform: translateY(1px);
  64. ${p =>
  65. p.orientation === 'horizontal'
  66. ? `
  67. /* Extra padding + negative margin trick, to expand click area */
  68. padding: ${space(0.75)} ${space(1)} ${space(1.5)};
  69. margin-left: -${space(1)};
  70. margin-right: -${space(1)};
  71. `
  72. : `padding: ${space(0.75)} ${space(2)};`};
  73. color: ${p => (p.selected ? p.theme.activeText : p.theme.textColor)};
  74. white-space: nowrap;
  75. cursor: pointer;
  76. &:hover {
  77. color: ${p => (p.selected ? p.theme.activeText : p.theme.headingColor)};
  78. }
  79. &:focus {
  80. outline: none;
  81. }
  82. ${p =>
  83. p.disabled &&
  84. `
  85. &, &:hover {
  86. color: ${p.theme.subText};
  87. pointer-events: none;
  88. }
  89. `}
  90. ${p =>
  91. p.overflowing &&
  92. `
  93. opacity: 0;
  94. pointer-events: none;
  95. `}
  96. `;
  97. const HoverLayer = styled('div')<{orientation: Orientation}>`
  98. position: absolute;
  99. left: 0;
  100. right: 0;
  101. top: 0;
  102. bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)};
  103. pointer-events: none;
  104. background-color: currentcolor;
  105. border-radius: inherit;
  106. z-index: 0;
  107. opacity: 0;
  108. transition: opacity 0.1s ease-out;
  109. li:hover:not(.focus-visible) > & {
  110. opacity: 0.06;
  111. }
  112. ${p =>
  113. p.orientation === 'vertical' &&
  114. `
  115. li[aria-selected='true']:not(.focus-visible) > & {
  116. opacity: 0.06;
  117. }
  118. `}
  119. `;
  120. const FocusLayer = styled('div')<{orientation: Orientation}>`
  121. position: absolute;
  122. left: 0;
  123. right: 0;
  124. top: 0;
  125. bottom: ${p => (p.orientation === 'horizontal' ? space(0.75) : 0)};
  126. pointer-events: none;
  127. border-radius: inherit;
  128. z-index: 0;
  129. transition: box-shadow 0.1s ease-out;
  130. li.focus-visible > & {
  131. box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px,
  132. inset ${p => p.theme.focusBorder} 0 0 0 1px;
  133. }
  134. `;
  135. const TabSelectionIndicator = styled('div')<{
  136. orientation: Orientation;
  137. selected: boolean;
  138. }>`
  139. position: absolute;
  140. border-radius: 2px;
  141. pointer-events: none;
  142. background: ${p => (p.selected ? p.theme.active : 'transparent')};
  143. transition: background 0.1s ease-out;
  144. li[aria-disabled='true'] & {
  145. background: ${p => (p.selected ? p.theme.subText : 'transparent')};
  146. }
  147. ${p =>
  148. p.orientation === 'horizontal'
  149. ? `
  150. width: calc(100% - ${space(2)});
  151. height: 3px;
  152. bottom: 0;
  153. left: 50%;
  154. transform: translateX(-50%);
  155. `
  156. : `
  157. width: 3px;
  158. height: 50%;
  159. left: 0;
  160. top: 50%;
  161. transform: translateY(-50%);
  162. `};
  163. `;