Skip to content

Commit

Permalink
Merge pull request #289 from LifeSG/input-mask
Browse files Browse the repository at this point in the history
Add masked input component
  • Loading branch information
qroll authored Aug 29, 2023
2 parents ec41973 + b267054 commit e679779
Show file tree
Hide file tree
Showing 10 changed files with 554 additions and 1 deletion.
38 changes: 38 additions & 0 deletions src/form/form-masked-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import { FormWrapper } from "./form-wrapper";
import { FormMaskedInputProps } from "./types";
import { MaskedInput } from "../masked-input/masked-input";

const Component = (
props: FormMaskedInputProps,
ref: React.Ref<HTMLInputElement>
): JSX.Element => {
const {
label,
errorMessage,
id = "form-field-masked-input",
"data-error-testid": errorTestId,
"data-testid": testId,
...otherProps
} = props;

return (
<FormWrapper
id={id}
label={label}
errorMessage={errorMessage}
disabled={otherProps.disabled}
data-error-testid={errorTestId}
>
<MaskedInput
ref={ref}
id={`${id}-base`}
data-testid={testId || id}
error={!!errorMessage}
{...otherProps}
/>
</FormWrapper>
);
};

export const FormMaskedInput = React.forwardRef(Component);
2 changes: 2 additions & 0 deletions src/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FormDateInput } from "./form-date-input";
import { FormDateRangeInput } from "./form-date-range-input";
import { FormInput } from "./form-input";
import { FormInputGroup } from "./form-input-group";
import { FormMaskedInput } from "./form-masked-input";
import { FormLabel } from "./form-label";
import { FormMultiSelect } from "./form-multi-select";
import { FormNestedSelect } from "./form-nested-select";
Expand All @@ -21,6 +22,7 @@ export const Form = {
DateRangeInput: FormDateRangeInput,
Input: FormInput,
InputGroup: FormInputGroup,
MaskedInput: FormMaskedInput,
Label: FormLabel,
MultiSelect: FormMultiSelect,
NestedSelect: FormNestedSelect,
Expand Down
5 changes: 5 additions & 0 deletions src/form/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DateInputProps } from "../date-input/types";
import { DateRangeInputProps } from "../date-range-input/types";
import { InputGroupPartialProps } from "../input-group/types";
import { MaskedInputPartialProps } from "../masked-input/types";
import { InputMultiSelectPartialProps } from "../input-multi-select/types";
import { InputNestedSelectPartialProps } from "../input-nested-select";
import { InputNestedMultiSelectPartialProps } from "../input-nested-multi-select";
Expand Down Expand Up @@ -58,6 +59,10 @@ export interface FormInputGroupProps<T, V>
extends InputGroupPartialProps<T, V>,
BaseFormElementProps {}

export interface FormMaskedInputProps
extends MaskedInputPartialProps,
BaseFormElementProps {}

export interface FormTextareaProps
extends TextareaPartialProps,
BaseFormElementProps {}
Expand Down
4 changes: 3 additions & 1 deletion src/input-group/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export interface AddonProps<T, V> {

export interface InputGroupProps<T, V> extends InputProps {
addon?: AddonProps<T, V> | undefined;
onBlur?: (() => void) | undefined;
// Note: the onBlur event argument is optional because the onBlur event from one
// of the addon types (ListAddon) does not originate from the input element
onBlur?: ((event?: React.FocusEvent<HTMLInputElement>) => void) | undefined;
}

/** To be exposed for Form component inheritance */
Expand Down
2 changes: 2 additions & 0 deletions src/masked-input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./masked-input";
export * from "./types";
41 changes: 41 additions & 0 deletions src/masked-input/masked-input.style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import styled from "styled-components";
import { Color } from "../color";
import { InputGroup } from "../input-group";

interface InputGroupWrapperProps {
readOnly: boolean;
$isDisabled: boolean;
}

interface IconProps {
$isDisabled?: boolean;
$inactiveColor: string;
$activeColor: string;
}

export const InputGroupWrapper = styled(InputGroup)<InputGroupWrapperProps>`
padding: 0 0 0 ${({ readOnly }) => (readOnly ? "0" : "1rem")};
input {
cursor: ${({ readOnly, $isDisabled }) =>
readOnly && !$isDisabled ? "pointer" : "initial"};
}
`;

export const IconContainer = styled.div<IconProps>`
display: flex;
height: calc(3rem - 2px);
width: 3.25rem;
align-items: center;
justify-content: center;
cursor: ${({ $isDisabled }) => (!$isDisabled ? "pointer" : "initial")};
color: ${({
$isDisabled,
$inactiveColor = Color.Neutral[3],
$activeColor = Color.Primary,
}) => ($isDisabled ? $inactiveColor : $activeColor)};
svg {
width: 1.125rem;
height: 1.125rem;
}
`;
175 changes: 175 additions & 0 deletions src/masked-input/masked-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import React, { useEffect, useState } from "react";
import { EyeIcon } from "@lifesg/react-icons/eye";
import { EyeSlashIcon } from "@lifesg/react-icons/eye-slash";
import { IconContainer, InputGroupWrapper } from "./masked-input.style";
import { MaskedInputProps } from "./types";
import { isEmpty } from "lodash";

const Component = (
{
value,
readOnly,
"data-testid": dataTestId,
maskRange,
unmaskRange,
maskRegex,
maskTransformer,
iconMask = <EyeSlashIcon />,
iconUnmask = <EyeIcon />,
iconActiveColor: maskIconActiveColor,
iconInactiveColor: maskIconInactiveColor,
maskChar = "•",
onMask,
onUnmask,
onChange,
onFocus,
onBlur,
error,
disableMask,
...otherProps
}: MaskedInputProps,
ref: React.Ref<HTMLInputElement>
) => {
const isEmptyReadOnlyState = readOnly && isEmpty(value);
const [isMasked, setIsMasked] = useState(!disableMask);
const [updatedValue, setUpdatedValue] = useState(value || "");

useEffect(() => {
if (isMasked) {
onMask && onMask();
} else {
onUnmask && onUnmask();
}
}, [isMasked]);

// =============================================================================
// EVENT HANDLERS
// =============================================================================

const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
setIsMasked(false);
onFocus && onFocus(event);
};

const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
setIsMasked(true);
onBlur && onBlur(event);
};

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUpdatedValue(event.target.value);
onChange && onChange(event);
};

const toggleMasking = (event?: React.MouseEvent<HTMLInputElement>) => {
setIsMasked(!isMasked);
};

// =============================================================================
// HELPER FUNCTIONS
// =============================================================================

const getValue = () => {
if (isEmptyReadOnlyState) {
return "-";
}

return isMasked && !disableMask
? maskValue(updatedValue?.toString())
: updatedValue;
};

const maskValue = (value: string): string => {
if (!value) {
return value;
}

if (maskRange) {
const { startIndex, endIndex } = determineStartAndEndIndex(
maskRange[0],
maskRange[1]
);
return (
value.substring(0, startIndex) +
maskChar.repeat(
value.substring(startIndex, endIndex + 1).length
) +
value.substring(endIndex + 1)
);
} else if (unmaskRange) {
const { startIndex, endIndex } = determineStartAndEndIndex(
unmaskRange[0],
unmaskRange[1]
);
return (
maskChar.repeat(value.substring(0, startIndex).length) +
value.substring(startIndex, endIndex + 1) +
maskChar.repeat(value.substring(endIndex + 1).length)
);
} else if (maskRegex) {
return value.replace(maskRegex, maskChar);
} else if (maskTransformer) {
return maskTransformer(value);
}

return value;
};

const determineStartAndEndIndex = (index0: number, index1: number) => {
return index0 < index1
? { startIndex: index0, endIndex: index1 }
: { startIndex: index1, endIndex: index0 };
};

const shouldDisableMasking = () =>
!updatedValue?.toString().length || disableMask;

// =============================================================================
// RENDER FUNCTIONS
// =============================================================================

const renderIcon = () => {
const isDisabled = shouldDisableMasking();

return (
!isEmptyReadOnlyState && (
<IconContainer
data-testid={`icon-${isMasked ? "masked" : "unmasked"}`}
onClick={!isDisabled ? toggleMasking : undefined}
$isDisabled={isDisabled}
$inactiveColor={maskIconInactiveColor}
$activeColor={maskIconActiveColor}
>
{isMasked ? iconUnmask : iconMask}
</IconContainer>
)
);
};

return (
<InputGroupWrapper
ref={ref}
data-testid={`${dataTestId || "masked-input"}${
isMasked ? "-masked" : "-unmasked"
}`}
addon={{
type: "custom",
attributes: {
children: renderIcon(),
},
position: "right",
}}
onFocus={!readOnly ? handleFocus : undefined}
onBlur={!readOnly ? handleBlur : undefined}
onClick={readOnly ? toggleMasking : undefined}
onChange={handleChange}
value={getValue()}
readOnly={readOnly}
error={error}
$isDisabled={shouldDisableMasking()}
{...otherProps}
/>
);
};

export const MaskedInput = React.forwardRef(Component);
19 changes: 19 additions & 0 deletions src/masked-input/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { InputProps } from "../input/types";

export interface MaskedInputProps extends InputProps {
maskRange?: number[] | undefined;
unmaskRange?: number[] | undefined;
maskRegex?: RegExp | undefined;
maskTransformer?: ((value: string) => string) | undefined;
iconMask?: JSX.Element;
iconUnmask?: JSX.Element;
iconActiveColor?: string | undefined;
iconInactiveColor?: string | undefined;
maskChar?: string | undefined;
onMask?: (() => void) | undefined;
onUnmask?: (() => void) | undefined;
disableMask?: boolean | undefined;
}

/** To be exposed for Form component inheritance */
export type MaskedInputPartialProps = Omit<MaskedInputProps, "error">;
Loading

0 comments on commit e679779

Please sign in to comment.