Skip to content

Commit 2d6bf0d

Browse files
authored
ref: growing input is no more (#86553)
All bow to useAutosizeInput and `<Input autosize/>` I have opted to use <Input autosize as opposed to requiring everyone to call the hook, as this allows us to ensure that the hook is correctly called (value is required depending on if the input is controlled or not), meaning that we can reduce the possible bug surface. https://github.com/user-attachments/assets/a321da42-e648-42c7-ad94-e885ba99bb12 The PR is missing tests, I had started to write some and realized mid way that I will have to mock pretty much all of the window styles methods, defeating the purpose of the tests (I will give that another go) The GrowingInput component was also hiding a bug that caused react to not throw warnings in development when only the value prop was provided as it was providing its own onchange handler and then calling `props.onChange?.()` which was always falsy **Below** is what I originally typed into the draft PR and explains the reasoning for ^ decision more in depth. The question now is, do we expose this via hook, or do we build it into the Input via autosize prop like `<Input autosize/>`? I think I am leaning towards autosize prop as I believe it is more intuitive than consuming a hook (which could still be exposed). If we require users to call the hook, then the API would look something like ```tsx function Component() { const ref = useAutosizeInput({value}) return <Input ref={ref}/> } ``` The awkward part is that exposing it like this could that the DOM structure could depend on the autosize prop (provided would use the CSS only approach that @natemoo-re shared, which requires a wrapper component). An alternative could be to add autosize to InputGroup and require the prop to be set there, but that feels weird, as the autosize input should be set on the input itself, and making InputGroup a requirement for autosize to work feels sub-optimal. ```tsx function Component(){ return ( <InputGroup autosize> // <- this feels weird, but also means that standalone inputs dont support autosize? <InputGroup.Input/> </InputGroup> } ``` Finally, and the version that I am most leaning towards is to expose this as an autosize bool prop on Input, and use the current JS handler to measure resizing. This way, we can also ensure that the hook is correctly called with the value props if the component is controlled, and hide that last bit of complexity from the user (provided we dont use the pure CSS solution for now) ```tsx function Component() { return <Input autosize/> } ``` @natemoo-re @TkDodo @evanpurkhiser or anyone else reading this, feedback and opinions would be much appreciated
1 parent c153061 commit 2d6bf0d

File tree

8 files changed

+237
-229
lines changed

8 files changed

+237
-229
lines changed
Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {Fragment} from 'react';
1+
import {Fragment, useState} from 'react';
22
import styled from '@emotion/styled';
33

44
import {Input} from 'sentry/components/core/input';
5+
import {useAutosizeInput} from 'sentry/components/core/input/useAutosizeInput';
56
import * as Storybook from 'sentry/stories';
67
import {space} from 'sentry/styles/space';
78

@@ -17,21 +18,24 @@ export default Storybook.story('Input', (story, APIReference) => {
1718
The <Storybook.JSXNode name="Input" /> component comes in different sizes:
1819
</p>
1920
<Grid>
20-
<label>
21-
<code>md (default):</code> <Input size="md" />
22-
</label>
23-
<label>
24-
<code>sm:</code> <Input size="sm" value="value" />
25-
</label>
26-
<label>
27-
<code>xs:</code> <Input size="xs" placeholder="placeholder" />
28-
</label>
21+
<Label>
22+
<code>md (default):</code> <Input size="md" defaultValue="" />
23+
</Label>
24+
<Label>
25+
<code>sm:</code> <Input size="sm" defaultValue="value" />
26+
</Label>
27+
<Label>
28+
<code>xs:</code> <Input size="xs" defaultValue="" placeholder="placeholder" />
29+
</Label>
2930
</Grid>
3031
</Fragment>
3132
);
3233
});
3334

3435
story('Locked', () => {
36+
const [value, setValue] = useState('this is aria-disabled');
37+
const [readonlyValue, setReadonlyValue] = useState('this is readonly');
38+
const [disabledValue, setDisabledValue] = useState('this is disabled');
3539
return (
3640
<Fragment>
3741
<p>
@@ -41,24 +45,112 @@ export default Storybook.story('Input', (story, APIReference) => {
4145
interactive like a <code>readonly</code> field:
4246
</p>
4347
<Grid>
44-
<label>
45-
<code>disabled:</code> <Input disabled value="this is disabled" />
46-
</label>
47-
<label>
48+
<Label>
49+
<code>disabled:</code>{' '}
50+
<Input
51+
disabled
52+
value={disabledValue}
53+
onChange={e => setDisabledValue(e.target.value)}
54+
/>
55+
</Label>
56+
<Label>
4857
<code>aria-disabled:</code>{' '}
49-
<Input aria-disabled value="this is aria-disabled" />
50-
</label>
51-
<label>
52-
<code>readonly:</code> <Input readOnly value="this is readonly" />
53-
</label>
58+
<Input
59+
aria-disabled
60+
value={value}
61+
onChange={e => {
62+
setValue(e.target.value);
63+
}}
64+
/>
65+
</Label>
66+
<Label>
67+
<code>readonly:</code>{' '}
68+
<Input
69+
readOnly
70+
value={readonlyValue}
71+
onChange={e => setReadonlyValue(e.target.value)}
72+
/>
73+
</Label>
74+
</Grid>
75+
</Fragment>
76+
);
77+
});
78+
79+
story('Autosize', () => {
80+
const [value, setValue] = useState('this is autosized');
81+
const [proxyValue, setProxyValue] = useState('this is autosized');
82+
83+
const controlledAutosizeRef = useAutosizeInput({
84+
value,
85+
});
86+
87+
const uncontrolledAutosizeRef = useAutosizeInput({
88+
value: proxyValue,
89+
});
90+
91+
const externalControlledAutosizeRef = useAutosizeInput({
92+
value: proxyValue,
93+
});
94+
95+
const placeholderAutosizeRef = useAutosizeInput();
96+
97+
return (
98+
<Fragment>
99+
<p>
100+
The <Storybook.JSXNode name="Input" /> component can automatically resize its
101+
width to fit its content when used with the <code>useAutosizeInput</code> hook.
102+
This hook provides a ref that should be passed to the input component. The input
103+
will expand horizontally while maintaining its height as the user types. See the
104+
examples below for how to use the hook with controlled and uncontrolled inputs.
105+
</p>
106+
107+
<p>
108+
If a placeholder if provided without a value, the input will autosize according
109+
to the placeholder size!
110+
</p>
111+
<Grid>
112+
<Label>
113+
<code>controlled input autosize:</code>{' '}
114+
<Input
115+
ref={controlledAutosizeRef}
116+
value={value}
117+
onChange={e => setValue(e.target.value)}
118+
/>
119+
</Label>
120+
<Label>
121+
<code>uncontrolled input autosize:</code>{' '}
122+
<Input ref={uncontrolledAutosizeRef} defaultValue="" />
123+
</Label>
124+
125+
<Label>
126+
<code>controlled via different input:</code>{' '}
127+
<Input value={proxyValue} onChange={e => setProxyValue(e.target.value)} />
128+
<Input ref={externalControlledAutosizeRef} readOnly value={proxyValue} />
129+
</Label>
130+
131+
<Label>
132+
<code>autosize according to placeholder:</code>{' '}
133+
<Input
134+
ref={placeholderAutosizeRef}
135+
defaultValue=""
136+
placeholder="placeholder"
137+
/>
138+
</Label>
54139
</Grid>
55140
</Fragment>
56141
);
57142
});
58143
});
59144

145+
const Label = styled('label')`
146+
display: flex;
147+
flex-direction: column;
148+
gap: ${space(1)};
149+
`;
150+
60151
const Grid = styled('div')`
61152
display: grid;
62-
grid-template-columns: repeat(3, 1fr);
153+
grid-template-columns: repeat(2, 1fr);
154+
grid-template-rows: repeat(2, 1fr);
63155
gap: ${space(2)};
64156
`;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type React from 'react';
2+
import {useCallback, useLayoutEffect, useRef} from 'react';
3+
4+
/**
5+
* This hook is used to automatically resize an input element based on its content.
6+
* It is useful for creating "growing" inputs that can resize to fit their content.
7+
*
8+
* @param options - Options for the autosize input functionality.
9+
* @param options.disabled - Set to `true` to disable the autosizing.
10+
* @param options.value - The value of the input, use when the input is controlled.
11+
* @returns A ref callback for the input element.
12+
*/
13+
14+
interface UseAutosizeInputOptions {
15+
enabled?: boolean;
16+
value?: React.InputHTMLAttributes<HTMLInputElement>['value'] | undefined;
17+
}
18+
19+
export function useAutosizeInput(
20+
options?: UseAutosizeInputOptions
21+
): React.RefCallback<HTMLInputElement> {
22+
const enabled = options?.enabled ?? true;
23+
const sourceRef = useRef<HTMLInputElement | null>(null);
24+
25+
// A controlled input value change does not trigger a change event,
26+
// so we need to manually observe the value...
27+
useLayoutEffect(() => {
28+
if (!enabled) {
29+
return;
30+
}
31+
32+
if (sourceRef.current) {
33+
resize(sourceRef.current);
34+
}
35+
}, [options?.value, enabled]);
36+
37+
const onInputChange = useCallback((_event: any) => {
38+
if (sourceRef.current) {
39+
resize(sourceRef.current);
40+
}
41+
}, []);
42+
43+
const autosizingCallbackRef: React.RefCallback<HTMLInputElement> = useCallback(
44+
(element: HTMLInputElement | null) => {
45+
if (!enabled || !element) {
46+
sourceRef.current?.removeEventListener('input', onInputChange);
47+
} else {
48+
resize(element);
49+
element.addEventListener('input', onInputChange);
50+
}
51+
52+
sourceRef.current = element;
53+
},
54+
[onInputChange, enabled]
55+
);
56+
57+
return autosizingCallbackRef;
58+
}
59+
60+
function createSizingDiv(referenceStyles: CSSStyleDeclaration) {
61+
const sizingDiv = document.createElement('div');
62+
sizingDiv.style.whiteSpace = 'pre';
63+
sizingDiv.style.width = 'auto';
64+
sizingDiv.style.height = '0';
65+
sizingDiv.style.position = 'fixed';
66+
sizingDiv.style.pointerEvents = 'none';
67+
sizingDiv.style.opacity = '0';
68+
sizingDiv.style.zIndex = '-1';
69+
70+
sizingDiv.style.fontSize = referenceStyles.fontSize;
71+
sizingDiv.style.fontWeight = referenceStyles.fontWeight;
72+
sizingDiv.style.fontFamily = referenceStyles.fontFamily;
73+
74+
return sizingDiv;
75+
}
76+
77+
function resize(input: HTMLInputElement) {
78+
const computedStyles = getComputedStyle(input);
79+
80+
const sizingDiv = createSizingDiv(computedStyles);
81+
sizingDiv.innerText = input.value || input.placeholder;
82+
document.body.appendChild(sizingDiv);
83+
84+
const newTotalInputSize =
85+
sizingDiv.offsetWidth +
86+
// parseInt is save here as the computed styles are always in px
87+
parseInt(computedStyles.paddingLeft ?? 0, 10) +
88+
parseInt(computedStyles.paddingRight ?? 0, 10) +
89+
parseInt(computedStyles.borderWidth ?? 0, 10) * 2 +
90+
1; // Add 1px to account for cursor width in Safari
91+
92+
document.body.removeChild(sizingDiv);
93+
input.style.width = `${newTotalInputSize}px`;
94+
}

static/app/components/growingInput.spec.tsx

Lines changed: 0 additions & 46 deletions
This file was deleted.

static/app/components/growingInput.stories.tsx

Lines changed: 0 additions & 72 deletions
This file was deleted.

0 commit comments

Comments
 (0)