inputGroup.tsx 5.8 KB

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