Uniformize folder structure (#693)
* Uniformize folder structure * Fix icons * Fix icons * Fix tests * Fix tests
This commit is contained in:
142
front/src/modules/ui/input/components/AutosizeTextInput.tsx
Normal file
142
front/src/modules/ui/input/components/AutosizeTextInput.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { HotkeysEvent } from 'react-hotkeys-hook/dist/types';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { RoundedIconButton } from '@/ui/button/components/RoundedIconButton';
|
||||
import { IconArrowRight } from '@/ui/icon/index';
|
||||
|
||||
const MAX_ROWS = 5;
|
||||
|
||||
type OwnProps = {
|
||||
onValidate?: (text: string) => void;
|
||||
minRows?: number;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledTextArea = styled(TextareaAutosize)`
|
||||
background: ${({ theme }) => theme.background.tertiary};
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-family: inherit;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
line-height: 16px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
}
|
||||
`;
|
||||
|
||||
// TODO: this messes with the layout, fix it
|
||||
const StyledBottomRightRoundedIconButton = styled.div`
|
||||
height: 0;
|
||||
position: relative;
|
||||
right: 26px;
|
||||
top: calc(100% - 26.5px);
|
||||
width: 0px;
|
||||
`;
|
||||
|
||||
export function AutosizeTextInput({
|
||||
placeholder,
|
||||
onValidate,
|
||||
minRows = 1,
|
||||
}: OwnProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const isSendButtonDisabled = !text;
|
||||
|
||||
useHotkeys(
|
||||
['shift+enter', 'enter'],
|
||||
(event: KeyboardEvent, handler: HotkeysEvent) => {
|
||||
if (handler.shift || !isFocused) {
|
||||
return;
|
||||
} else {
|
||||
event.preventDefault();
|
||||
|
||||
onValidate?.(text);
|
||||
|
||||
setText('');
|
||||
}
|
||||
},
|
||||
{
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[onValidate, text, setText, isFocused],
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
(event: KeyboardEvent) => {
|
||||
if (!isFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
setText('');
|
||||
},
|
||||
{
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[onValidate, setText, isFocused],
|
||||
);
|
||||
|
||||
function handleInputChange(event: React.FormEvent<HTMLTextAreaElement>) {
|
||||
const newText = event.currentTarget.value;
|
||||
|
||||
setText(newText);
|
||||
}
|
||||
|
||||
function handleOnClickSendButton() {
|
||||
onValidate?.(text);
|
||||
|
||||
setText('');
|
||||
}
|
||||
|
||||
const computedMinRows = minRows > MAX_ROWS ? MAX_ROWS : minRows;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContainer>
|
||||
<StyledTextArea
|
||||
placeholder={placeholder || 'Write a comment'}
|
||||
maxRows={MAX_ROWS}
|
||||
minRows={computedMinRows}
|
||||
onChange={handleInputChange}
|
||||
value={text}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<StyledBottomRightRoundedIconButton>
|
||||
<RoundedIconButton
|
||||
onClick={handleOnClickSendButton}
|
||||
icon={<IconArrowRight size={15} />}
|
||||
disabled={isSendButtonDisabled}
|
||||
/>
|
||||
</StyledBottomRightRoundedIconButton>
|
||||
</StyledContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
front/src/modules/ui/input/components/Checkbox.tsx
Normal file
99
front/src/modules/ui/input/components/Checkbox.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconCheck, IconMinus } from '@/ui/icon';
|
||||
|
||||
type OwnProps = {
|
||||
checked: boolean;
|
||||
indeterminate?: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input<{ indeterminate?: boolean }>`
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
|
||||
& + label {
|
||||
cursor: pointer;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
& + label:before {
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.strong};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
content: '';
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
&:checked + label:before {
|
||||
background: ${({ theme }) => theme.color.blue};
|
||||
border-color: ${({ theme }) => theme.color.blue};
|
||||
}
|
||||
|
||||
& + label:before {
|
||||
background: ${({ theme, indeterminate }) =>
|
||||
indeterminate ? theme.color.blue : theme.background.primary};
|
||||
border-color: ${({ theme, indeterminate }) =>
|
||||
indeterminate ? theme.color.blue : theme.border.color.inverted};
|
||||
}
|
||||
|
||||
& + label > svg {
|
||||
height: 12px;
|
||||
left: 1px;
|
||||
position: absolute;
|
||||
stroke: ${({ theme }) => theme.grayScale.gray0};
|
||||
top: 1px;
|
||||
width: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function Checkbox({ checked, onChange, indeterminate }: OwnProps) {
|
||||
const [isInternalChecked, setIsInternalChecked] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsInternalChecked(checked);
|
||||
}, [checked]);
|
||||
|
||||
function handleChange(value: boolean) {
|
||||
onChange(value);
|
||||
setIsInternalChecked(!isInternalChecked);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledInputContainer>
|
||||
<StyledInput
|
||||
type="checkbox"
|
||||
name="styled-checkbox"
|
||||
data-testid="input-checkbox"
|
||||
checked={isInternalChecked}
|
||||
indeterminate={indeterminate}
|
||||
onChange={(event) => handleChange(event.target.checked)}
|
||||
/>
|
||||
<label htmlFor="checkbox">
|
||||
{indeterminate ? (
|
||||
<IconMinus />
|
||||
) : isInternalChecked ? (
|
||||
<IconCheck />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</label>
|
||||
</StyledInputContainer>
|
||||
);
|
||||
}
|
||||
270
front/src/modules/ui/input/components/DatePicker.tsx
Normal file
270
front/src/modules/ui/input/components/DatePicker.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
import React, { forwardRef, ReactElement, useState } from 'react';
|
||||
import ReactDatePicker, { CalendarContainerProps } from 'react-datepicker';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { overlayBackground } from '@/ui/themes/effects';
|
||||
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
|
||||
export type DatePickerProps = {
|
||||
isOpen?: boolean;
|
||||
date: Date;
|
||||
onChangeHandler: (date: Date) => void;
|
||||
customInput?: ReactElement;
|
||||
customCalendarContainer?(props: CalendarContainerProps): React.ReactNode;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
& .react-datepicker {
|
||||
border-color: ${({ theme }) => theme.border.color.light};
|
||||
background: transparent;
|
||||
font-family: 'Inter';
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
border: none;
|
||||
display: block;
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
}
|
||||
|
||||
& .react-datepicker-popper {
|
||||
position: relative !important;
|
||||
inset: auto !important;
|
||||
transform: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
& .react-datepicker__triangle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__triangle::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Header
|
||||
|
||||
& .react-datepicker__header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__header__dropdown {
|
||||
display: flex;
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
|
||||
& .react-datepicker__month-dropdown-container,
|
||||
& .react-datepicker__year-dropdown-container {
|
||||
text-align: left;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
margin-right: 0;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
padding-right: ${({ theme }) => theme.spacing(4)};
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
}
|
||||
|
||||
& .react-datepicker__month-read-view--down-arrow,
|
||||
& .react-datepicker__year-read-view--down-arrow {
|
||||
height: 5px;
|
||||
width: 5px;
|
||||
border-width: 1px 1px 0 0;
|
||||
border-color: ${({ theme }) => theme.border.color.light};
|
||||
top: 3px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
& .react-datepicker__year-read-view,
|
||||
& .react-datepicker__month-read-view {
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
|
||||
& .react-datepicker__month-dropdown-container {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
& .react-datepicker__year-dropdown-container {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
& .react-datepicker__month-dropdown,
|
||||
& .react-datepicker__year-dropdown {
|
||||
border: ${({ theme }) => theme.border.color.light};
|
||||
${overlayBackground}
|
||||
overflow-y: scroll;
|
||||
top: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
& .react-datepicker__month-dropdown {
|
||||
left: ${({ theme }) => theme.spacing(2)};
|
||||
width: 160px;
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
& .react-datepicker__year-dropdown {
|
||||
left: calc(${({ theme }) => theme.spacing(9)} + 80px);
|
||||
width: 100px;
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
& .react-datepicker__navigation--years {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__month-option--selected,
|
||||
& .react-datepicker__year-option--selected {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__year-option,
|
||||
& .react-datepicker__month-option {
|
||||
text-align: left;
|
||||
padding: ${({ theme }) => theme.spacing(2)}
|
||||
calc(${({ theme }) => theme.spacing(2)} - 2px);
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(4)});
|
||||
border-radius: ${({ theme }) => theme.border.radius.xs};
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
cursor: pointer;
|
||||
margin: 2px;
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .react-datepicker__current-month {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__day-name {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
width: 34px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
& .react-datepicker__month-container {
|
||||
float: none;
|
||||
}
|
||||
|
||||
// Days
|
||||
|
||||
& .react-datepicker__month {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& .react-datepicker__day {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
& .react-datepicker__navigation--previous,
|
||||
& .react-datepicker__navigation--next {
|
||||
height: 34px;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding-top: 6px;
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
}
|
||||
& .react-datepicker__navigation--previous {
|
||||
right: 38px;
|
||||
top: 8px;
|
||||
left: auto;
|
||||
|
||||
& > span {
|
||||
margin-left: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
& .react-datepicker__navigation--next {
|
||||
right: 6px;
|
||||
top: 8px;
|
||||
|
||||
& > span {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
& .react-datepicker__navigation-icon::before {
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
border-width: 1px 1px 0 0;
|
||||
border-color: ${({ theme }) => theme.font.color.tertiary};
|
||||
}
|
||||
|
||||
& .react-datepicker__day--keyboard-selected {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
& .react-datepicker__day,
|
||||
.react-datepicker__time-name {
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
}
|
||||
|
||||
& .react-datepicker__day--selected {
|
||||
background-color: ${({ theme }) => theme.color.blue};
|
||||
color: ${({ theme }) => theme.font.color.inverted};
|
||||
}
|
||||
|
||||
& .react-datepicker__day--outside-month {
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
}
|
||||
|
||||
& .react-datepicker__day:hover {
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
}
|
||||
`;
|
||||
|
||||
function DatePicker({
|
||||
date,
|
||||
onChangeHandler,
|
||||
customInput,
|
||||
customCalendarContainer,
|
||||
}: DatePickerProps) {
|
||||
const [startDate, setStartDate] = useState(date);
|
||||
|
||||
type DivProps = React.HTMLProps<HTMLDivElement>;
|
||||
|
||||
const DefaultDateDisplay = forwardRef<HTMLDivElement, DivProps>(
|
||||
({ value, onClick }, ref) => (
|
||||
<div onClick={onClick} ref={ref}>
|
||||
{value &&
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value as string))}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<ReactDatePicker
|
||||
open={true}
|
||||
selected={startDate}
|
||||
showMonthDropdown
|
||||
showYearDropdown
|
||||
onChange={(date: Date) => {
|
||||
setStartDate(date);
|
||||
onChangeHandler(date);
|
||||
}}
|
||||
customInput={customInput ? customInput : <DefaultDateDisplay />}
|
||||
calendarContainer={
|
||||
customCalendarContainer ? customCalendarContainer : undefined
|
||||
}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatePicker;
|
||||
145
front/src/modules/ui/input/components/ImageInput.tsx
Normal file
145
front/src/modules/ui/input/components/ImageInput.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Button } from '@/ui/button/components/Button';
|
||||
import { IconFileUpload, IconTrash, IconUpload } from '@/ui/icon';
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const Picture = styled.button<{ withPicture: boolean }>`
|
||||
align-items: center;
|
||||
background: ${({ theme, disabled }) =>
|
||||
disabled ? theme.background.secondary : theme.background.tertiary};
|
||||
border: none;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
||||
display: flex;
|
||||
height: 66px;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
width: 66px;
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
${({ theme, withPicture, disabled }) => {
|
||||
if (withPicture || disabled) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
&:hover {
|
||||
background: ${theme.background.quaternary};
|
||||
}
|
||||
`;
|
||||
}};
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin-left: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
> * + * {
|
||||
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
`;
|
||||
|
||||
const Text = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
`;
|
||||
|
||||
const StyledHiddenFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
type Props = Omit<React.ComponentProps<'div'>, 'children'> & {
|
||||
picture: string | null | undefined;
|
||||
onUpload?: (file: File) => void;
|
||||
onRemove?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ImageInput({
|
||||
picture,
|
||||
onUpload,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
...restProps
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const hiddenFileInput = React.useRef<HTMLInputElement>(null);
|
||||
const onUploadButtonClick = () => {
|
||||
hiddenFileInput.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container {...restProps}>
|
||||
<Picture
|
||||
withPicture={!!picture}
|
||||
disabled={disabled}
|
||||
onClick={onUploadButtonClick}
|
||||
>
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture || '/images/default-profile-picture.png'}
|
||||
alt="profile"
|
||||
/>
|
||||
) : (
|
||||
<IconFileUpload size={theme.icon.size.md} />
|
||||
)}
|
||||
</Picture>
|
||||
<Content>
|
||||
<ButtonContainer>
|
||||
<StyledHiddenFileInput
|
||||
type="file"
|
||||
ref={hiddenFileInput}
|
||||
onChange={(event) => {
|
||||
if (onUpload) {
|
||||
if (event.target.files) {
|
||||
onUpload(event.target.files[0]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconUpload size={theme.icon.size.sm} />}
|
||||
onClick={onUploadButtonClick}
|
||||
variant="secondary"
|
||||
title="Upload"
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
icon={<IconTrash size={theme.icon.size.sm} />}
|
||||
onClick={onRemove}
|
||||
variant="secondary"
|
||||
title="Remove"
|
||||
disabled={!picture || disabled}
|
||||
fullWidth
|
||||
/>
|
||||
</ButtonContainer>
|
||||
<Text>
|
||||
We support your best PNGs, JPEGs and GIFs portraits under 10MB
|
||||
</Text>
|
||||
</Content>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
184
front/src/modules/ui/input/components/TextInput.tsx
Normal file
184
front/src/modules/ui/input/components/TextInput.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import {
|
||||
ChangeEvent,
|
||||
FocusEventHandler,
|
||||
InputHTMLAttributes,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { usePreviousHotkeyScope } from '@/ui/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
|
||||
import { IconAlertCircle } from '@/ui/icon';
|
||||
import { IconEye, IconEyeOff } from '@/ui/icon/index';
|
||||
|
||||
import { InputHotkeyScope } from '../types/InputHotkeyScope';
|
||||
|
||||
type OwnProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
||||
label?: string;
|
||||
onChange?: (text: string) => void;
|
||||
fullWidth?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div<Pick<OwnProps, 'fullWidth'>>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: ${({ fullWidth, theme }) => (fullWidth ? `100%` : 'auto')};
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input<Pick<OwnProps, 'fullWidth'>>`
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
border: none;
|
||||
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
outline: none;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
width: 100%;
|
||||
|
||||
&::placeholder,
|
||||
&::-webkit-input-placeholder {
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledErrorHelper = styled.div`
|
||||
color: ${({ theme }) => theme.color.red};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledTrailingIconContainer = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-right: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledTrailingIcon = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const INPUT_TYPE_PASSWORD = 'password';
|
||||
|
||||
export function TextInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
fullWidth,
|
||||
error,
|
||||
required,
|
||||
type,
|
||||
...props
|
||||
}: OwnProps): JSX.Element {
|
||||
const theme = useTheme();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
goBackToPreviousHotkeyScope,
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
} = usePreviousHotkeyScope();
|
||||
|
||||
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
onFocus?.(e);
|
||||
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
|
||||
};
|
||||
|
||||
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
onBlur?.(e);
|
||||
goBackToPreviousHotkeyScope();
|
||||
};
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Enter, Key.Escape],
|
||||
() => {
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
InputHotkeyScope.TextInput,
|
||||
);
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
|
||||
const handleTogglePasswordVisibility = () => {
|
||||
setPasswordVisible(!passwordVisible);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer fullWidth={fullWidth ?? false}>
|
||||
{label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>}
|
||||
<StyledInputContainer>
|
||||
<StyledInput
|
||||
ref={inputRef}
|
||||
tabIndex={props.tabIndex ?? 0}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
value={value}
|
||||
required={required}
|
||||
type={passwordVisible ? 'text' : type}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (onChange) {
|
||||
onChange(event.target.value);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<StyledTrailingIconContainer>
|
||||
{error && (
|
||||
<StyledTrailingIcon>
|
||||
<IconAlertCircle size={16} color={theme.color.red} />
|
||||
</StyledTrailingIcon>
|
||||
)}
|
||||
{!error && type === INPUT_TYPE_PASSWORD && (
|
||||
<StyledTrailingIcon
|
||||
onClick={handleTogglePasswordVisibility}
|
||||
data-testid="reveal-password-button"
|
||||
>
|
||||
{passwordVisible ? (
|
||||
<IconEyeOff size={theme.icon.size.md} />
|
||||
) : (
|
||||
<IconEye size={theme.icon.size.md} />
|
||||
)}
|
||||
</StyledTrailingIcon>
|
||||
)}
|
||||
</StyledTrailingIconContainer>
|
||||
</StyledInputContainer>
|
||||
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { AutosizeTextInput } from '../AutosizeTextInput';
|
||||
|
||||
const meta: Meta<typeof AutosizeTextInput> = {
|
||||
title: 'UI/Inputs/AutosizeTextInput',
|
||||
component: AutosizeTextInput,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AutosizeTextInput>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: getRenderWrapperForComponent(<AutosizeTextInput />),
|
||||
};
|
||||
@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { jest } from '@storybook/jest';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { TextInput } from '../TextInput';
|
||||
|
||||
const meta: Meta<typeof TextInput> = {
|
||||
title: 'UI/Inputs/TextInput',
|
||||
component: TextInput,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TextInput>;
|
||||
|
||||
const changeJestFn = jest.fn();
|
||||
|
||||
function FakeTextInput({ onChange }: any) {
|
||||
const [value, setValue] = useState<string>('A good value ');
|
||||
return (
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(text) => {
|
||||
setValue(text);
|
||||
onChange(text);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<FakeTextInput onChange={changeJestFn} />,
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByRole('textbox');
|
||||
await userEvent.type(input, 'cou', { delay: 100 });
|
||||
|
||||
expect(changeJestFn).toHaveBeenNthCalledWith(1, 'A good value c');
|
||||
expect(changeJestFn).toHaveBeenNthCalledWith(2, 'A good value co');
|
||||
expect(changeJestFn).toHaveBeenNthCalledWith(3, 'A good value cou');
|
||||
},
|
||||
};
|
||||
|
||||
export const Placeholder: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TextInput value="" onChange={changeJestFn} placeholder="Placeholder" />,
|
||||
),
|
||||
};
|
||||
|
||||
export const FullWidth: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TextInput
|
||||
value="A good value"
|
||||
onChange={changeJestFn}
|
||||
placeholder="Placeholder"
|
||||
fullWidth
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const PasswordInput: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TextInput
|
||||
onChange={changeJestFn}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
/>,
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByPlaceholderText('Password');
|
||||
await userEvent.type(input, 'pa$$w0rd');
|
||||
|
||||
const revealButton = canvas.getByTestId('reveal-password-button');
|
||||
await userEvent.click(revealButton);
|
||||
|
||||
expect(input).toHaveAttribute('type', 'text');
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user