inputGroup.tsx 6.7 KB

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