Browse Source

ref(combo-box): Split growing input into separate component (#68857)

Move the logic for auto-growing the input from the combobox into a
re-usable component.
ComboBox: add prop `growingInput`
ArthurKnaus 11 months ago
parent
commit
957bdaecf4

+ 25 - 13
static/app/components/comboBox/comboBox.stories.tsx

@@ -74,19 +74,31 @@ export default storyBook('ComboBox', story => {
   story('With list size limit', () => {
     const [value, setValue] = useState('opt_one');
     return (
-      <Fragment>
-        <SizingWindow display="block" style={{overflow: 'visible'}}>
-          <ComboBox
-            value={value}
-            onChange={({value: newValue}) => setValue(newValue)}
-            aria-label="ComboBox"
-            menuTrigger="focus"
-            placeholder="Select an Option"
-            sizeLimit={5}
-            options={options}
-          />
-        </SizingWindow>
-      </Fragment>
+      <SizingWindow display="block" style={{overflow: 'visible'}}>
+        <ComboBox
+          value={value}
+          onChange={({value: newValue}) => setValue(newValue)}
+          aria-label="ComboBox"
+          menuTrigger="focus"
+          placeholder="Select an Option"
+          sizeLimit={5}
+          options={options}
+        />
+      </SizingWindow>
+    );
+  });
+
+  story('With growing input', () => {
+    return (
+      <SizingWindow display="block" style={{overflow: 'visible'}}>
+        <ComboBox
+          aria-label="ComboBox"
+          menuTrigger="focus"
+          placeholder="Select an Option"
+          growingInput
+          options={options}
+        />
+      </SizingWindow>
     );
   });
 

+ 10 - 39
static/app/components/comboBox/index.tsx

@@ -1,12 +1,4 @@
-import {
-  useCallback,
-  useContext,
-  useEffect,
-  useLayoutEffect,
-  useMemo,
-  useRef,
-  useState,
-} from 'react';
+import {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
 import isPropValid from '@emotion/is-prop-valid';
 import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
@@ -23,6 +15,7 @@ import {
   getHiddenOptions,
   getItemsWithKeys,
 } from 'sentry/components/compactSelect/utils';
+import {GrowingInput} from 'sentry/components/growingInput';
 import Input from 'sentry/components/input';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {Overlay, PositionWrapper} from 'sentry/components/overlay';
@@ -45,6 +38,7 @@ interface ComboBoxProps<Value extends string>
   'aria-label': string;
   className?: string;
   disabled?: boolean;
+  growingInput?: boolean;
   isLoading?: boolean;
   loadingMessage?: string;
   menuSize?: FormSize;
@@ -64,6 +58,7 @@ function ComboBox<Value extends string>({
   loadingMessage,
   sizeLimitMessage,
   menuTrigger = 'focus',
+  growingInput = false,
   menuWidth,
   ...props
 }: ComboBoxProps<Value>) {
@@ -71,7 +66,6 @@ function ComboBox<Value extends string>({
   const listBoxRef = useRef<HTMLUListElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
   const popoverRef = useRef<HTMLDivElement>(null);
-  const sizingRef = useRef<HTMLDivElement>(null);
 
   const state = useComboBoxState({
     // Mapping our disabled prop to react-aria's isDisabled
@@ -83,23 +77,6 @@ function ComboBox<Value extends string>({
     state
   );
 
-  // Sync input width with sizing div
-  // TODO: think of making this configurable with a prop
-  // TODO: extract into separate component
-  useLayoutEffect(() => {
-    if (sizingRef.current && inputRef.current) {
-      const computedStyles = window.getComputedStyle(inputRef.current);
-
-      const newTotalInputSize =
-        sizingRef.current.offsetWidth +
-        parseInt(computedStyles.paddingLeft, 10) +
-        parseInt(computedStyles.paddingRight, 10) +
-        parseInt(computedStyles.borderWidth, 10) * 2;
-
-      inputRef.current.style.width = `${newTotalInputSize}px`;
-    }
-  }, [state.inputValue]);
-
   // Make popover width constant while it is open
   useEffect(() => {
     if (!menuWidth && popoverRef.current && state.isOpen) {
@@ -136,6 +113,8 @@ function ComboBox<Value extends string>({
     }
   }, [state, menuTrigger]);
 
+  const InputComponent = growingInput ? StyledGrowingInput : StyledInput;
+
   return (
     <SelectContext.Provider
       value={{
@@ -144,16 +123,13 @@ function ComboBox<Value extends string>({
       }}
     >
       <ControlWrapper className={className}>
-        <StyledInput
+        <InputComponent
           {...inputProps}
           onClick={handleInputClick}
           placeholder={placeholder}
           ref={mergeRefs([inputRef, triggerProps.ref])}
           size={size}
         />
-        <SizingDiv aria-hidden ref={sizingRef} size={size}>
-          {state.inputValue}
-        </SizingDiv>
         <StyledPositionWrapper
           {...overlayProps}
           zIndex={theme.zIndex?.tooltip}
@@ -337,14 +313,9 @@ const StyledInput = styled(Input)`
   max-width: inherit;
   min-width: inherit;
 `;
-
-const SizingDiv = styled('div')<{size?: FormSize}>`
-  opacity: 0;
-  pointer-events: none;
-  z-index: -1;
-  position: fixed;
-  white-space: pre;
-  font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};
+const StyledGrowingInput = styled(GrowingInput)`
+  max-width: inherit;
+  min-width: inherit;
 `;
 
 const StyledPositionWrapper = styled(PositionWrapper, {

+ 46 - 0
static/app/components/growingInput.spec.tsx

@@ -0,0 +1,46 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {GrowingInput} from 'sentry/components/growingInput';
+
+describe('GrowingInput', () => {
+  it('can be controlled', () => {
+    const {rerender} = render(
+      <GrowingInput aria-label="Label" value="Lorem ipsum dolor" />
+    );
+    const inputBefore = screen.getByRole('textbox', {name: 'Label'});
+    expect(inputBefore).toHaveValue('Lorem ipsum dolor');
+    expect(inputBefore).toBeInTheDocument();
+
+    rerender(<GrowingInput aria-label="Label" value="Lorem ipsum dolor sit amat" />);
+    const inputAfter = screen.getByRole('textbox', {name: 'Label'});
+    expect(inputAfter).toHaveValue('Lorem ipsum dolor sit amat');
+    expect(inputAfter).toBeInTheDocument();
+  });
+
+  it('can be uncontrolled', async () => {
+    const handleChange = jest.fn();
+    render(
+      <GrowingInput
+        onChange={handleChange}
+        aria-label="Label"
+        defaultValue="Lorem ipsum dolor"
+      />
+    );
+    const inputBefore = screen.getByRole('textbox', {name: 'Label'});
+    expect(inputBefore).toHaveValue('Lorem ipsum dolor');
+    expect(inputBefore).toBeInTheDocument();
+
+    await userEvent.type(inputBefore, ' sit amat');
+
+    expect(handleChange).toHaveBeenCalledTimes(9);
+    expect(handleChange).toHaveBeenLastCalledWith(
+      expect.objectContaining({
+        target: expect.objectContaining({value: 'Lorem ipsum dolor sit amat'}),
+      })
+    );
+
+    const inputAfter = screen.getByRole('textbox', {name: 'Label'});
+    expect(inputAfter).toHaveValue('Lorem ipsum dolor sit amat');
+    expect(inputAfter).toBeInTheDocument();
+  });
+});

+ 73 - 0
static/app/components/growingInput.stories.tsx

@@ -0,0 +1,73 @@
+import {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {GrowingInput} from 'sentry/components/growingInput';
+import Input from 'sentry/components/input';
+import {Slider} from 'sentry/components/slider';
+import SizingWindow from 'sentry/components/stories/sizingWindow';
+import storyBook from 'sentry/stories/storyBook';
+
+export default storyBook('GrowingInput', story => {
+  story('Uncontrolled', () => {
+    return (
+      <SizingWindow display="block">
+        <GrowingInput defaultValue="Lorem ipsum dolor sit amat" />
+      </SizingWindow>
+    );
+  });
+
+  story('Controlled', () => {
+    const [value, setValue] = useState('Lorem ipsum dolor sit amat');
+    return (
+      <Fragment>
+        This input is synced with the growing one:
+        <Input value={value} onChange={e => setValue(e.target.value)} />
+        <br />
+        <br />
+        <SizingWindow display="block">
+          <GrowingInput value={value} onChange={e => setValue(e.target.value)} />
+        </SizingWindow>
+      </Fragment>
+    );
+  });
+
+  story('Style with min and max width', () => {
+    const [minWidth, setMinWidth] = useState(20);
+    const [maxWidth, setMaxWidth] = useState(60);
+    return (
+      <Fragment>
+        <p>The input respects the min and max width styles.</p>
+        <SizingWindow display="block">
+          <Slider
+            label="Min width"
+            min={0}
+            max={100}
+            step={1}
+            value={minWidth}
+            onChange={value => setMinWidth(value as number)}
+          />
+          <Slider
+            label="Max width"
+            min={0}
+            max={100}
+            step={1}
+            value={maxWidth}
+            onChange={value => setMaxWidth(value as number)}
+          />
+          <br />
+          <StyledGrowingInput
+            defaultValue={'Lorem ipsum dolor sit amat'}
+            minWidth={minWidth}
+            maxWidth={maxWidth}
+            placeholder="Type something here..."
+          />
+        </SizingWindow>
+      </Fragment>
+    );
+  });
+});
+
+const StyledGrowingInput = styled(GrowingInput)<{maxWidth: number; minWidth: number}>`
+  min-width: ${p => p.minWidth}%;
+  max-width: ${p => p.maxWidth}%;
+`;

+ 82 - 0
static/app/components/growingInput.tsx

@@ -0,0 +1,82 @@
+import {forwardRef, useCallback, useEffect, useLayoutEffect, useRef} from 'react';
+import styled from '@emotion/styled';
+
+import Input, {type InputProps} from 'sentry/components/input';
+import mergeRefs from 'sentry/utils/mergeRefs';
+
+function createSizingDiv(referenceStyles: CSSStyleDeclaration) {
+  const sizingDiv = document.createElement('div');
+  sizingDiv.style.whiteSpace = 'pre';
+  sizingDiv.style.width = 'auto';
+  sizingDiv.style.height = '0';
+  sizingDiv.style.position = 'fixed';
+  sizingDiv.style.pointerEvents = 'none';
+  sizingDiv.style.opacity = '0';
+  sizingDiv.style.zIndex = '-1';
+
+  sizingDiv.style.fontSize = referenceStyles.fontSize;
+  sizingDiv.style.fontWeight = referenceStyles.fontWeight;
+  sizingDiv.style.fontFamily = referenceStyles.fontFamily;
+
+  return sizingDiv;
+}
+
+function resize(input: HTMLInputElement) {
+  const computedStyles = window.getComputedStyle(input);
+
+  const sizingDiv = createSizingDiv(computedStyles);
+  sizingDiv.innerText = input.value;
+  document.body.appendChild(sizingDiv);
+
+  const newTotalInputSize =
+    sizingDiv.offsetWidth +
+    // parseInt is save here as the computed styles are always in px
+    parseInt(computedStyles.paddingLeft, 10) +
+    parseInt(computedStyles.paddingRight, 10) +
+    parseInt(computedStyles.borderWidth, 10) * 2;
+
+  document.body.removeChild(sizingDiv);
+
+  input.style.width = `${newTotalInputSize}px`;
+}
+
+export const GrowingInput = forwardRef<HTMLInputElement, InputProps>(
+  function GrowingInput({onChange, ...props}: InputProps, ref) {
+    const inputRef = useRef<HTMLInputElement>(null);
+    const isControlled = props.value !== undefined;
+
+    // If the input is controlled we resize it when the value prop changes
+    useLayoutEffect(() => {
+      if (isControlled && inputRef.current) {
+        resize(inputRef.current);
+      }
+    }, [props.value, isControlled]);
+
+    // If the input is uncontrolled we resize it when the user types
+    const handleChange = useCallback(
+      (event: React.ChangeEvent<HTMLInputElement>) => {
+        if (!isControlled) {
+          resize(event.target);
+        }
+        onChange?.(event);
+      },
+      [onChange, isControlled]
+    );
+
+    // If the input is uncontrolled we resize it when it is mounted (default value)
+    useEffect(() => {
+      if (!isControlled && inputRef.current) {
+        resize(inputRef.current);
+      }
+    }, [isControlled]);
+
+    useLayoutEffect(() => {});
+    return (
+      <StyledInput {...props} ref={mergeRefs([ref, inputRef])} onChange={handleChange} />
+    );
+  }
+);
+
+const StyledInput = styled(Input)`
+  width: 0;
+`;

+ 1 - 0
static/app/views/metrics/queryBuilder.tsx

@@ -250,6 +250,7 @@ export const QueryBuilder = memo(function QueryBuilder({
             options={mriOptions}
             value={metricsQuery.mri}
             onChange={handleMRIChange}
+            growingInput
             menuWidth="400px"
           />
         ) : (