Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 112 additions & 20 deletions static/app/components/core/input/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {Fragment} from 'react';
import {Fragment, useState} from 'react';
import styled from '@emotion/styled';

import {Input} from 'sentry/components/core/input';
import {useAutosizeInput} from 'sentry/components/core/input/useAutosizeInput';
import * as Storybook from 'sentry/stories';
import {space} from 'sentry/styles/space';

Expand All @@ -18,21 +19,24 @@ export default Storybook.story('Input', (story, APIReference) => {
The <Storybook.JSXNode name="Input" /> component comes in different sizes:
</p>
<Grid>
<label>
<code>md (default):</code> <Input size="md" />
</label>
<label>
<code>sm:</code> <Input size="sm" value="value" />
</label>
<label>
<code>xs:</code> <Input size="xs" placeholder="placeholder" />
</label>
<Label>
<code>md (default):</code> <Input size="md" defaultValue="" />
</Label>
<Label>
<code>sm:</code> <Input size="sm" defaultValue="value" />
</Label>
<Label>
<code>xs:</code> <Input size="xs" defaultValue="" placeholder="placeholder" />
</Label>
</Grid>
</Fragment>
);
});

story('Locked', () => {
const [value, setValue] = useState('this is aria-disabled');
const [readonlyValue, setReadonlyValue] = useState('this is readonly');
const [disabledValue, setDisabledValue] = useState('this is disabled');
return (
<Fragment>
<p>
Expand All @@ -42,24 +46,112 @@ export default Storybook.story('Input', (story, APIReference) => {
interactive like a <code>readonly</code> field:
</p>
<Grid>
<label>
<code>disabled:</code> <Input disabled value="this is disabled" />
</label>
<label>
<Label>
<code>disabled:</code>{' '}
<Input
disabled
value={disabledValue}
onChange={e => setDisabledValue(e.target.value)}
/>
</Label>
<Label>
<code>aria-disabled:</code>{' '}
<Input aria-disabled value="this is aria-disabled" />
</label>
<label>
<code>readonly:</code> <Input readOnly value="this is readonly" />
</label>
<Input
aria-disabled
value={value}
onChange={e => {
setValue(e.target.value);
}}
/>
</Label>
<Label>
<code>readonly:</code>{' '}
<Input
readOnly
value={readonlyValue}
onChange={e => setReadonlyValue(e.target.value)}
/>
</Label>
</Grid>
</Fragment>
);
});

story('Autosize', () => {
const [value, setValue] = useState('this is autosized');
const [proxyValue, setProxyValue] = useState('this is autosized');

const controlledAutosizeRef = useAutosizeInput({
value,
});

const uncontrolledAutosizeRef = useAutosizeInput({
value: proxyValue,
});

const externalControlledAutosizeRef = useAutosizeInput({
value: proxyValue,
});

const placeholderAutosizeRef = useAutosizeInput();

return (
<Fragment>
<p>
The <Storybook.JSXNode name="Input" /> component can automatically resize its
width to fit its content when used with the <code>useAutosizeInput</code> hook.
This hook provides a ref that should be passed to the input component. The input
will expand horizontally while maintaining its height as the user types. See the
examples below for how to use the hook with controlled and uncontrolled inputs.
</p>

<p>
If a placeholder if provided without a value, the input will autosize according
to the placeholder size!
</p>
<Grid>
<Label>
<code>controlled input autosize:</code>{' '}
<Input
ref={controlledAutosizeRef}
value={value}
onChange={e => setValue(e.target.value)}
/>
</Label>
<Label>
<code>uncontrolled input autosize:</code>{' '}
<Input ref={uncontrolledAutosizeRef} defaultValue="" />
</Label>

<Label>
<code>controlled via different input:</code>{' '}
<Input value={proxyValue} onChange={e => setProxyValue(e.target.value)} />
<Input ref={externalControlledAutosizeRef} readOnly value={proxyValue} />
</Label>

<Label>
<code>autosize according to placeholder:</code>{' '}
<Input
ref={placeholderAutosizeRef}
defaultValue=""
placeholder="placeholder"
/>
</Label>
</Grid>
</Fragment>
);
});
});

const Label = styled('label')`
display: flex;
flex-direction: column;
gap: ${space(1)};
`;

const Grid = styled('div')`
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: ${space(2)};
`;
94 changes: 94 additions & 0 deletions static/app/components/core/input/useAutosizeInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type React from 'react';
import {useCallback, useLayoutEffect, useRef} from 'react';

/**
* This hook is used to automatically resize an input element based on its content.
* It is useful for creating "growing" inputs that can resize to fit their content.
*
* @param options - Options for the autosize input functionality.
* @param options.disabled - Set to `true` to disable the autosizing.
* @param options.value - The value of the input, use when the input is controlled.
* @returns A ref callback for the input element.
*/

interface UseAutosizeInputOptions {
enabled?: boolean;
value?: React.InputHTMLAttributes<HTMLInputElement>['value'] | undefined;
}

export function useAutosizeInput(
options?: UseAutosizeInputOptions
): React.RefCallback<HTMLInputElement> {
const enabled = options?.enabled ?? true;
const sourceRef = useRef<HTMLInputElement | null>(null);

// A controlled input value change does not trigger a change event,
// so we need to manually observe the value...
useLayoutEffect(() => {
if (!enabled) {
return;
}

if (sourceRef.current) {
resize(sourceRef.current);
}
}, [options?.value, enabled]);

const onInputChange = useCallback((_event: any) => {
if (sourceRef.current) {
resize(sourceRef.current);
}
}, []);

const autosizingCallbackRef: React.RefCallback<HTMLInputElement> = useCallback(
(element: HTMLInputElement | null) => {
if (!enabled || !element) {
sourceRef.current?.removeEventListener('input', onInputChange);
} else {
resize(element);
element.addEventListener('input', onInputChange);
}

sourceRef.current = element;
},
[onInputChange, enabled]
);

return autosizingCallbackRef;
}

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 = getComputedStyle(input);

const sizingDiv = createSizingDiv(computedStyles);
sizingDiv.innerText = input.value || input.placeholder;
document.body.appendChild(sizingDiv);

const newTotalInputSize =
sizingDiv.offsetWidth +
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can just use calc(${n}ch + padding instead of querying the width. If we can, then we might be able to remove other parts of the sizing functionality as well

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: we cant because ch unit is apparently based on the the 0 character. When I tested this, I was seeing a drift between the input and width which grew with the size of the input

// parseInt is save here as the computed styles are always in px
parseInt(computedStyles.paddingLeft ?? 0, 10) +
parseInt(computedStyles.paddingRight ?? 0, 10) +
parseInt(computedStyles.borderWidth ?? 0, 10) * 2 +
1; // Add 1px to account for cursor width in Safari

document.body.removeChild(sizingDiv);
input.style.width = `${newTotalInputSize}px`;
}
46 changes: 0 additions & 46 deletions static/app/components/growingInput.spec.tsx

This file was deleted.

72 changes: 0 additions & 72 deletions static/app/components/growingInput.stories.tsx

This file was deleted.

Loading
Loading