inputGroup.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import {
  2. createContext,
  3. forwardRef,
  4. useContext,
  5. useLayoutEffect,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. import {css, Theme} from '@emotion/react';
  11. import styled from '@emotion/styled';
  12. import _TextArea, {TextAreaProps} from 'sentry/components/forms/controls/textarea';
  13. import _Input, {InputProps} from 'sentry/components/input';
  14. import {space} from 'sentry/styles/space';
  15. import {FormSize} from 'sentry/utils/theme';
  16. interface InputContext {
  17. /**
  18. * Props passed to `Input` element (`size`, `disabled`), useful for styling
  19. * `InputGroup.LeadingItems` and `InputGroup.TrailingItems`.
  20. */
  21. inputProps: Pick<InputProps, 'size' | 'disabled'>;
  22. /**
  23. * Width of the leading items wrap, to be added to `Input`'s padding.
  24. */
  25. leadingWidth?: number;
  26. setInputProps?: (props: Pick<InputProps, 'size' | 'disabled'>) => void;
  27. setLeadingWidth?: (width: number) => void;
  28. setTrailingWidth?: (width: number) => void;
  29. /**
  30. * Width of the trailing items wrap, to be added to `Input`'s padding.
  31. */
  32. trailingWidth?: number;
  33. }
  34. export const InputGroupContext = createContext<InputContext>({inputProps: {}});
  35. /**
  36. * Wrapper for input group. To be used alongisde `Input`, `InputGroup.LeadingItems`,
  37. * and `InputGroup.TrailingItems`:
  38. * <InputGroup>
  39. * <InputGroup.LeadingItems> … </InputGroup.LeadingItems>
  40. * <Input />
  41. * <InputGroup.TrailingItems> … </InputGroup.TrailingItems>
  42. * </InputGroup>
  43. */
  44. function InputGroup({children, ...props}: React.HTMLAttributes<HTMLDivElement>) {
  45. const [leadingWidth, setLeadingWidth] = useState<number>();
  46. const [trailingWidth, setTrailingWidth] = useState<number>();
  47. const [inputProps, setInputProps] = useState<Partial<InputProps>>({});
  48. const contextValue = useMemo(
  49. () => ({
  50. inputProps,
  51. setInputProps,
  52. leadingWidth,
  53. setLeadingWidth,
  54. trailingWidth,
  55. setTrailingWidth,
  56. }),
  57. [inputProps, leadingWidth, trailingWidth]
  58. );
  59. return (
  60. <InputGroupContext.Provider value={contextValue}>
  61. <InputGroupWrap disabled={inputProps.disabled} {...props}>
  62. {children}
  63. </InputGroupWrap>
  64. </InputGroupContext.Provider>
  65. );
  66. }
  67. const Input = forwardRef<HTMLInputElement, InputProps>(
  68. ({size, disabled, ...props}, ref) => {
  69. const {leadingWidth, trailingWidth, setInputProps} = useContext(InputGroupContext);
  70. useLayoutEffect(() => {
  71. setInputProps?.({size, disabled});
  72. }, [size, disabled, setInputProps]);
  73. return (
  74. <StyledInput
  75. ref={ref}
  76. leadingWidth={leadingWidth}
  77. trailingWidth={trailingWidth}
  78. size={size}
  79. disabled={disabled}
  80. {...props}
  81. />
  82. );
  83. }
  84. );
  85. const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
  86. ({size, disabled, ...props}, ref) => {
  87. const {leadingWidth, trailingWidth, setInputProps} = useContext(InputGroupContext);
  88. useLayoutEffect(() => {
  89. setInputProps?.({size, disabled});
  90. }, [size, disabled, setInputProps]);
  91. return (
  92. <StyledTextArea
  93. ref={ref}
  94. leadingWidth={leadingWidth}
  95. trailingWidth={trailingWidth}
  96. size={size}
  97. disabled={disabled}
  98. {...props}
  99. />
  100. );
  101. }
  102. );
  103. interface InputItemsProps extends React.HTMLAttributes<HTMLDivElement> {
  104. /**
  105. * Whether to disable pointer events on the leading/trailing item wrap. This
  106. * should be set to true when none of the items inside the wrap are
  107. * interactive (e.g. a leading search icon). That way, mouse clicks will
  108. * fall through to the `Input` underneath and trigger a focus event.
  109. */
  110. disablePointerEvents?: boolean;
  111. }
  112. /**
  113. * Container for leading input items (e.g. a search icon). To be wrapped
  114. * inside `InputGroup`:
  115. * <InputGroup>
  116. * <InputGroup.LeadingItems> … </InputGroup.LeadingItems>
  117. * <Input />
  118. * </InputGroup>
  119. */
  120. function LeadingItems({children, disablePointerEvents, ...props}: InputItemsProps) {
  121. const ref = useRef<HTMLDivElement | null>(null);
  122. const {
  123. inputProps: {size = 'md', disabled},
  124. setLeadingWidth,
  125. } = useContext(InputGroupContext);
  126. useLayoutEffect(() => {
  127. if (!ref.current) {
  128. return;
  129. }
  130. setLeadingWidth?.(ref.current.offsetWidth);
  131. }, [children, setLeadingWidth, size]);
  132. return (
  133. <InputLeadingItemsWrap
  134. ref={ref}
  135. size={size}
  136. disablePointerEvents={disabled || disablePointerEvents}
  137. data-test-id="input-leading-items"
  138. {...props}
  139. >
  140. {children}
  141. </InputLeadingItemsWrap>
  142. );
  143. }
  144. /**
  145. * Container for trailing input items (e.g. a clear button). To be wrapped
  146. * inside `InputGroup`:
  147. * <InputGroup>
  148. * <Input />
  149. * <InputGroup.TrailingItems> … </InputGroup.TrailingItems>
  150. * </InputGroup>
  151. */
  152. function TrailingItems({children, disablePointerEvents, ...props}: InputItemsProps) {
  153. const ref = useRef<HTMLDivElement | null>(null);
  154. const {
  155. inputProps: {size = 'md', disabled},
  156. setTrailingWidth,
  157. } = useContext(InputGroupContext);
  158. useLayoutEffect(() => {
  159. if (!ref.current) {
  160. return;
  161. }
  162. setTrailingWidth?.(ref.current.offsetWidth);
  163. }, [children, setTrailingWidth, size]);
  164. return (
  165. <InputTrailingItemsWrap
  166. ref={ref}
  167. size={size}
  168. disablePointerEvents={disabled || disablePointerEvents}
  169. data-test-id="input-trailing-items"
  170. {...props}
  171. >
  172. {children}
  173. </InputTrailingItemsWrap>
  174. );
  175. }
  176. InputGroup.Input = Input;
  177. InputGroup.TextArea = TextArea;
  178. InputGroup.LeadingItems = LeadingItems;
  179. InputGroup.TrailingItems = TrailingItems;
  180. export {InputGroup};
  181. export type {InputProps, TextAreaProps};
  182. export const InputGroupWrap = styled('div')<{disabled?: boolean}>`
  183. position: relative;
  184. ${p => p.disabled && `color: ${p.theme.disabled};`};
  185. `;
  186. const InputItemsWrap = styled('div')`
  187. display: grid;
  188. grid-auto-flow: column;
  189. align-items: center;
  190. gap: ${space(1)};
  191. position: absolute;
  192. top: 50%;
  193. transform: translateY(-50%);
  194. `;
  195. interface InputStyleProps {
  196. leadingWidth?: number;
  197. size?: FormSize;
  198. trailingWidth?: number;
  199. }
  200. const getInputStyles = ({
  201. leadingWidth,
  202. trailingWidth,
  203. size,
  204. theme,
  205. }: InputStyleProps & {theme: Theme}) => css`
  206. ${leadingWidth &&
  207. `
  208. padding-left: calc(
  209. ${theme.formPadding[size ?? 'md'].paddingLeft}px * 1.5
  210. + ${leadingWidth}px
  211. );
  212. `}
  213. ${trailingWidth &&
  214. `
  215. padding-right: calc(
  216. ${theme.formPadding[size ?? 'md'].paddingRight}px * 1.5
  217. + ${trailingWidth}px
  218. );
  219. `}
  220. `;
  221. const StyledInput = styled(_Input)<InputStyleProps>`
  222. ${getInputStyles}
  223. `;
  224. const StyledTextArea = styled(_TextArea)<InputStyleProps>`
  225. ${getInputStyles}
  226. `;
  227. const InputLeadingItemsWrap = styled(InputItemsWrap)<{
  228. size: FormSize;
  229. disablePointerEvents?: boolean;
  230. }>`
  231. left: ${p => p.theme.formPadding[p.size].paddingLeft + 1}px;
  232. ${p => p.disablePointerEvents && `pointer-events: none;`}
  233. `;
  234. const InputTrailingItemsWrap = styled(InputItemsWrap)<{
  235. size: FormSize;
  236. disablePointerEvents?: boolean;
  237. }>`
  238. right: ${p => p.theme.formPadding[p.size].paddingRight * 0.75 + 1}px;
  239. ${p => p.disablePointerEvents && `pointer-events: none;`}
  240. `;