mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
278 lines
7.1 KiB
TypeScript
278 lines
7.1 KiB
TypeScript
import {
|
|
createContext,
|
|
forwardRef,
|
|
useContext,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import type {Theme} from '@emotion/react';
|
|
import {css} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
|
|
import type {TextAreaProps} from 'sentry/components/forms/controls/textarea';
|
|
import _TextArea from 'sentry/components/forms/controls/textarea';
|
|
import type {InputProps} from 'sentry/components/input';
|
|
import _Input from 'sentry/components/input';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {FormSize} from 'sentry/utils/theme';
|
|
|
|
interface InputContext {
|
|
/**
|
|
* Props passed to `Input` element (`size`, `disabled`), useful for styling
|
|
* `InputGroup.LeadingItems` and `InputGroup.TrailingItems`.
|
|
*/
|
|
inputProps: Pick<InputProps, 'size' | 'disabled'>;
|
|
/**
|
|
* Width of the leading items wrap, to be added to `Input`'s padding.
|
|
*/
|
|
leadingWidth?: number;
|
|
setInputProps?: (props: Pick<InputProps, 'size' | 'disabled'>) => void;
|
|
setLeadingWidth?: (width: number) => void;
|
|
setTrailingWidth?: (width: number) => void;
|
|
/**
|
|
* Width of the trailing items wrap, to be added to `Input`'s padding.
|
|
*/
|
|
trailingWidth?: number;
|
|
}
|
|
export const InputGroupContext = createContext<InputContext>({inputProps: {}});
|
|
|
|
/**
|
|
* Wrapper for input group. To be used alongisde `Input`, `InputGroup.LeadingItems`,
|
|
* and `InputGroup.TrailingItems`:
|
|
* <InputGroup>
|
|
* <InputGroup.LeadingItems> … </InputGroup.LeadingItems>
|
|
* <Input />
|
|
* <InputGroup.TrailingItems> … </InputGroup.TrailingItems>
|
|
* </InputGroup>
|
|
*/
|
|
function InputGroup({children, ...props}: React.HTMLAttributes<HTMLDivElement>) {
|
|
const [leadingWidth, setLeadingWidth] = useState<number>();
|
|
const [trailingWidth, setTrailingWidth] = useState<number>();
|
|
const [inputProps, setInputProps] = useState<Partial<InputProps>>({});
|
|
|
|
const contextValue = useMemo(
|
|
() => ({
|
|
inputProps,
|
|
setInputProps,
|
|
leadingWidth,
|
|
setLeadingWidth,
|
|
trailingWidth,
|
|
setTrailingWidth,
|
|
}),
|
|
[inputProps, leadingWidth, trailingWidth]
|
|
);
|
|
|
|
return (
|
|
<InputGroupContext.Provider value={contextValue}>
|
|
<InputGroupWrap disabled={inputProps.disabled} {...props}>
|
|
{children}
|
|
</InputGroupWrap>
|
|
</InputGroupContext.Provider>
|
|
);
|
|
}
|
|
|
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
({size, disabled, ...props}, ref) => {
|
|
const {leadingWidth, trailingWidth, setInputProps} = useContext(InputGroupContext);
|
|
|
|
useLayoutEffect(() => {
|
|
setInputProps?.({size, disabled});
|
|
}, [size, disabled, setInputProps]);
|
|
|
|
return (
|
|
<StyledInput
|
|
ref={ref}
|
|
leadingWidth={leadingWidth}
|
|
trailingWidth={trailingWidth}
|
|
size={size}
|
|
disabled={disabled}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
|
({size, disabled, ...props}, ref) => {
|
|
const {leadingWidth, trailingWidth, setInputProps} = useContext(InputGroupContext);
|
|
|
|
useLayoutEffect(() => {
|
|
setInputProps?.({size, disabled});
|
|
}, [size, disabled, setInputProps]);
|
|
|
|
return (
|
|
<StyledTextArea
|
|
ref={ref}
|
|
leadingWidth={leadingWidth}
|
|
trailingWidth={trailingWidth}
|
|
size={size}
|
|
disabled={disabled}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
interface InputItemsProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
/**
|
|
* Whether to disable pointer events on the leading/trailing item wrap. This
|
|
* should be set to true when none of the items inside the wrap are
|
|
* interactive (e.g. a leading search icon). That way, mouse clicks will
|
|
* fall through to the `Input` underneath and trigger a focus event.
|
|
*/
|
|
disablePointerEvents?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Container for leading input items (e.g. a search icon). To be wrapped
|
|
* inside `InputGroup`:
|
|
* <InputGroup>
|
|
* <InputGroup.LeadingItems> … </InputGroup.LeadingItems>
|
|
* <Input />
|
|
* </InputGroup>
|
|
*/
|
|
function LeadingItems({children, disablePointerEvents, ...props}: InputItemsProps) {
|
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
const {
|
|
inputProps: {size = 'md', disabled},
|
|
setLeadingWidth,
|
|
} = useContext(InputGroupContext);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!ref.current) {
|
|
return;
|
|
}
|
|
setLeadingWidth?.(ref.current.offsetWidth);
|
|
}, [children, setLeadingWidth, size]);
|
|
|
|
return (
|
|
<InputLeadingItemsWrap
|
|
ref={ref}
|
|
size={size}
|
|
disablePointerEvents={disabled || disablePointerEvents}
|
|
data-test-id="input-leading-items"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</InputLeadingItemsWrap>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Container for trailing input items (e.g. a clear button). To be wrapped
|
|
* inside `InputGroup`:
|
|
* <InputGroup>
|
|
* <Input />
|
|
* <InputGroup.TrailingItems> … </InputGroup.TrailingItems>
|
|
* </InputGroup>
|
|
*/
|
|
function TrailingItems({children, disablePointerEvents, ...props}: InputItemsProps) {
|
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
const {
|
|
inputProps: {size = 'md', disabled},
|
|
setTrailingWidth,
|
|
} = useContext(InputGroupContext);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!ref.current) {
|
|
return;
|
|
}
|
|
setTrailingWidth?.(ref.current.offsetWidth);
|
|
}, [children, setTrailingWidth, size]);
|
|
|
|
return (
|
|
<InputTrailingItemsWrap
|
|
ref={ref}
|
|
size={size}
|
|
disablePointerEvents={disabled || disablePointerEvents}
|
|
data-test-id="input-trailing-items"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</InputTrailingItemsWrap>
|
|
);
|
|
}
|
|
|
|
InputGroup.Input = Input;
|
|
InputGroup.TextArea = TextArea;
|
|
InputGroup.LeadingItems = LeadingItems;
|
|
InputGroup.TrailingItems = TrailingItems;
|
|
|
|
export {InputGroup};
|
|
export type {InputProps, TextAreaProps};
|
|
|
|
export const InputGroupWrap = styled('div')<{disabled?: boolean}>`
|
|
position: relative;
|
|
${p => p.disabled && `color: ${p.theme.disabled};`};
|
|
`;
|
|
|
|
const InputItemsWrap = styled('div')`
|
|
display: grid;
|
|
grid-auto-flow: column;
|
|
align-items: center;
|
|
gap: ${space(1)};
|
|
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
`;
|
|
|
|
interface InputStyleProps {
|
|
leadingWidth?: number;
|
|
size?: FormSize;
|
|
trailingWidth?: number;
|
|
}
|
|
|
|
const getInputStyles = ({
|
|
leadingWidth,
|
|
trailingWidth,
|
|
size,
|
|
theme,
|
|
}: InputStyleProps & {theme: Theme}) => css`
|
|
${
|
|
leadingWidth &&
|
|
`
|
|
padding-left: calc(
|
|
${theme.formPadding[size ?? 'md'].paddingLeft}px * 1.5
|
|
+ ${leadingWidth}px
|
|
);
|
|
`
|
|
}
|
|
|
|
${
|
|
trailingWidth &&
|
|
`
|
|
padding-right: calc(
|
|
${theme.formPadding[size ?? 'md'].paddingRight}px * 1.5
|
|
+ ${trailingWidth}px
|
|
);
|
|
`
|
|
}
|
|
`;
|
|
|
|
const StyledInput = styled(_Input)<InputStyleProps>`
|
|
${getInputStyles}
|
|
`;
|
|
|
|
const StyledTextArea = styled(_TextArea)<InputStyleProps>`
|
|
${getInputStyles}
|
|
`;
|
|
|
|
const InputLeadingItemsWrap = styled(InputItemsWrap)<{
|
|
size: FormSize;
|
|
disablePointerEvents?: boolean;
|
|
}>`
|
|
left: ${p => p.theme.formPadding[p.size].paddingLeft + 1}px;
|
|
${p => p.disablePointerEvents && `pointer-events: none;`}
|
|
`;
|
|
|
|
const InputTrailingItemsWrap = styled(InputItemsWrap)<{
|
|
size: FormSize;
|
|
disablePointerEvents?: boolean;
|
|
}>`
|
|
right: ${p => p.theme.formPadding[p.size].paddingRight * 0.75 + 1}px;
|
|
${p => p.disablePointerEvents && `pointer-events: none;`}
|
|
`;
|