Skip to content

Commit

Permalink
feat: update timepicker styles (#741)
Browse files Browse the repository at this point in the history
* WIP: re design TimePicker component

affects: @medly-components/core, @medly-components/forms, @medly-components/theme

* test: add unit test cases for timepicker

affects: @medly-components/core, @medly-components/forms

* refactor: timepicker styles

affects: @medly-components/core
  • Loading branch information
gmukul01 authored Jun 17, 2024
1 parent 35f7fe7 commit bb23bd5
Show file tree
Hide file tree
Showing 20 changed files with 869 additions and 638 deletions.
17 changes: 12 additions & 5 deletions packages/core/src/components/TextField/getMaskedValue.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import getMaskedValue from './getMaskedValue';

describe('getMaskedValue function', () => {
// @ts-ignore
const maskedValue = (value: string, selectionStart?: number) => getMaskedValue({ target: { value, selectionStart } }, 'DD / MM / YYYY');

// @ts-expect-error
const maskedValue = (value: string, selectionStart?: number, data?: string | null = null) =>
// @ts-expect-error
getMaskedValue({ target: { value, selectionStart }, nativeEvent: { data } }, 'DD / MM / YYYY');

it('should return truncated value if selectionStart value is less then value length', () => {
expect(maskedValue('11 / 11 / 1111', 6)).toEqual({ maskedValue: '11 / 1 1 / 1111', selectionStart: 6 });
it('should add blank space on deleting any non special character and move cursor after the blank space', () => {
expect(maskedValue('11 / 1 / 1111', 5, null)).toEqual({ maskedValue: '11 / 1 / 1111', selectionStart: 6 });
});

it('should not move cursor on deleting any special character', () => {
expect(maskedValue('11 / 1 / 1111', 4, null)).toEqual({ maskedValue: '11 / 1 / 1111', selectionStart: 4 });
});

it('should not add extra char if value length is equal to mask length', () => {
expect(maskedValue('11 / 11 / 11111')).toEqual({ maskedValue: '11 / 11 / 1111', selectionStart: 14 });
});

it('should add special character in between', () => {
it('should add special character automatically in between', () => {
expect(maskedValue('11 / 111')).toEqual({ maskedValue: '11 / 11 / 1', selectionStart: 11 });
});
});
13 changes: 7 additions & 6 deletions packages/core/src/components/TextField/getMaskedValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const applyMasking = (value: string, mask: string, selectionStart: number): stri

let newValue;

if (length > mask.length || alphaRegex.test(lastChar)) {
if (length > mask.length) {
// if user types more char then mask length
newValue = value.slice(0, -1);
} else if (
Expand All @@ -32,11 +32,12 @@ export const getMaskedValue = (event: React.ChangeEvent<HTMLInputElement>, mask:

const specialCharsRegex = /[^a-zA-Z0-9]/g, //NOSONAR
{ value, selectionStart } = event.target;

//TODO: Need to remove this if, when we handle masking when user deletes from the middle of the text
if (selectionStart && selectionStart < value.length) {
maskedValue = `${value.slice(0, selectionStart)} ${value.slice(selectionStart)}`;
return { maskedValue, selectionStart };

//TODO: Need to remove this if, when we handle masking when user deletes from the middle of the text
if (selectionStart !== null && selectionStart < value.length && value.length !== mask.length) {
// @ts-expect-error
maskedValue = `${value.slice(0, selectionStart)}${event.nativeEvent?.data === null ? ' ' : ''}${value.slice(selectionStart)}`;
return { maskedValue, selectionStart: specialCharsRegex.test(value[selectionStart]) ? selectionStart : selectionStart + 1 };
} else {
maskedValue = value
.replace(specialCharsRegex, '')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { centerAligned } from '@medly-components/utils';
import styled, { css } from 'styled-components';

export const TimePicker = styled.div`
height: 100%;
display: flex;
flex-direction: column;
gap: 2.4rem;
align-items: center;
justify-content: center;
overflow: hidden;
z-index: 1;
`;

export const TimeUList = styled.ul`
height: 100%;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow: auto;
scroll-snap-type: y mandatory;
user-select: none;
list-style: none;
&::-webkit-scrollbar {
display: none;
}
`;

const getFontStyle = (style: 'selectedOption' | 'nonSelectedOption') => css`
font-size: ${({ theme }) => theme.timePicker[style].fontSize};
font-weight: ${({ theme }) => theme.font.weights[theme.timePicker[style].fontWeight]};
line-height: ${({ theme }) => theme.timePicker[style].lineHeight};
letter-spacing: ${({ theme }) => theme.timePicker[style].LetterSpacing};
color: ${({ theme }) => theme.timePicker[style].color};
`;

export const TimeItem = styled('li')<{ isSelected?: boolean }>`
${centerAligned()}
cursor: pointer;
min-height: 4rem;
scroll-snap-align: center;
transition: all 200ms ease-in-ease-out;
${({ isSelected }) => getFontStyle(isSelected ? 'selectedOption' : 'nonSelectedOption')};
&:hover {
text-decoration: ${({ isSelected }) => (isSelected ? 'underline' : 'none')};
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { FC } from 'react';
import { forwardRef } from 'react';
import { TimeItem, TimePicker, TimeUList } from './TimeOptionList.styled';
import type { TimeOptionListProps } from './types';

const TIME_OPTIONS_LENGTH = {
HOUR: 12,
MINUTES: 60,
PERIOD: 2
};
const PERIOD = ['AM', 'PM'];

export const TimeOptionList: FC<TimeOptionListProps> = forwardRef<HTMLUListElement, TimeOptionListProps>(
({ value, onChange, type }, ref) => {
const isPeriod = type === 'PERIOD';
const handleScroll = (e: React.UIEvent<HTMLUListElement>) => {
const height = e.currentTarget.scrollHeight / (TIME_OPTIONS_LENGTH[type] + 4);
const value = Math.floor((e.currentTarget.scrollTop || 0) / height);
onChange(type, value);
};

const handleClick = (index: number) => () => (ref as any)?.current?.scrollTo({ top: index * 40, behavior: 'smooth' });

return (
<TimePicker>
<TimeUList ref={ref} onScroll={handleScroll} aria-label={`${type} list`}>
<TimeItem key="-2" />
<TimeItem key="-1" />
{Array.from({ length: TIME_OPTIONS_LENGTH[type] }, (_, index) => (
<TimeItem
key={index}
isSelected={index === value}
onClick={handleClick(index)}
aria-label={isPeriod ? PERIOD[index] : `${index} ${type}`}
>
{isPeriod ? PERIOD[index] : `0${index}`.slice(-2)}
</TimeItem>
))}
<TimeItem key="+1" />
<TimeItem key="+2" />
</TimeUList>
</TimePicker>
);
}
);
TimeOptionList.displayName = 'TimeOptionList';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TimeOptionList';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { HTMLProps } from '@medly-components/utils';

export type TIME_OPTION_TYPE = 'HOUR' | 'MINUTES' | 'PERIOD';

export type TimeOptionListProps = Omit<HTMLProps<HTMLUListElement>, 'onChange'> & {
type: TIME_OPTION_TYPE;
value: number;
onChange: (type: TIME_OPTION_TYPE, value: number) => void;
};
5 changes: 0 additions & 5 deletions packages/core/src/components/TimePicker/TimePicker.styled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@ export const TimePickerWrapper = styled(Popover)<{ fullWidth?: boolean; minWidth
& > ${OuterWrapper} {
margin: 0;
}
input[type='time']::-webkit-calendar-picker-indicator {
background: none;
display: none;
}
`;

export const TimeIcon = styled(AccessTimeIcon).attrs({ title: 'time-icon' })`
Expand Down
81 changes: 41 additions & 40 deletions packages/core/src/components/TimePicker/TimePicker.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { fireEvent, render, screen } from '@test-utils';
import { fireEvent, render, screen, waitFor } from '@test-utils';
import { TimePicker } from './TimePicker';

describe('TimePicker', () => {
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 24 });
// @ts-ignore
Element.prototype.scrollTo = function ({ top }) {
this.scrollTop = top;
};
});

afterEach(() => {
Object.defineProperty(global?.navigator, 'userAgent', { configurable: true, value: { indexOf: () => -1 } });
});

it('should render properly', () => {
const { container } = render(<TimePicker label="Time" value="13:11" onChange={jest.fn()} />);
fireEvent.click(screen.getByLabelText('Time'));
Expand All @@ -23,52 +18,58 @@ describe('TimePicker', () => {
it('should give time entered in the textfield', () => {
const mockOnChange = jest.fn();
render(<TimePicker label="Time" value="13:11" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Time'), { target: { value: '22:00' } });
fireEvent.change(screen.getByLabelText('Time'), { target: { value: '10 : 00 PM' } });
fireEvent.blur(screen.getByLabelText('Time'));
expect(mockOnChange).toBeCalledWith('22:00');
});

it('should give the expected time on selecting time from dialog', () => {
it('should select AM as default', async () => {
const mockOnChange = jest.fn();
render(<TimePicker label="Time" value="" onChange={mockOnChange} />);
fireEvent.click(screen.getByLabelText('Time'));
fireEvent.click(screen.getByTitle('hour-arrow-down'));
fireEvent.click(screen.getByTitle('minutes-arrow-down'));
fireEvent.click(screen.getByText('PM'));
fireEvent.click(screen.getByText('Apply'));
expect(mockOnChange).toBeCalledWith('13:01');
render(<TimePicker label="Time" value="13:11" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Time'), { target: { value: '10 : 00' } });
fireEvent.blur(screen.getByLabelText('Time'));
expect(mockOnChange).toBeCalledWith('10:00');
});

it('should reset the values on clicking on cancel button', () => {
it('should give the expected time on scrolling through list in the dialog', async () => {
const mockOnChange = jest.fn();
render(<TimePicker label="Time" value="" onChange={mockOnChange} />);
fireEvent.click(screen.getByLabelText('Time'));
fireEvent.click(screen.getByTitle('hour-arrow-down'));
fireEvent.click(screen.getByTitle('minutes-arrow-down'));
fireEvent.click(screen.getByText('PM'));
fireEvent.click(screen.getByText('Cancel'));
expect(mockOnChange).not.toBeCalled();
expect(screen.queryByText('hour-arrow-down')).not.toBeInTheDocument();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 640 });
Object.defineProperty(HTMLElement.prototype, 'scrollTop', { configurable: true, value: 11 * 40 });
fireEvent.scroll(screen.getByRole('list', { name: 'HOUR list' }));
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 2560 });
Object.defineProperty(HTMLElement.prototype, 'scrollTop', { configurable: true, value: 11 * 40 });
fireEvent.scroll(screen.getByRole('list', { name: 'MINUTES list' }));
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 240 });
Object.defineProperty(HTMLElement.prototype, 'scrollTop', { configurable: true, value: 1 * 40 });
fireEvent.scroll(screen.getByRole('list', { name: 'PERIOD list' }));
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => expect(mockOnChange).toBeCalledWith('23:11'));
});

it('should not render dialog for mobile devices', () => {
Object.defineProperty(global?.navigator, 'userAgent', { configurable: true, value: { indexOf: () => 1 } });
render(<TimePicker label="Time" value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByLabelText('Time'));
expect(screen.queryByText('hour-arrow-down')).not.toBeInTheDocument();
});
describe('error messages', () => {
it('should render error message if required', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: jest.fn() });
render(<TimePicker required label="Time" value="" onChange={jest.fn()} />);
fireEvent.blur(screen.getByLabelText('Time'));
expect(await screen.findByText('Constraints not satisfied')).toBeInTheDocument();
});

it('should render error message if required', async () => {
render(<TimePicker required label="Time" value="" onChange={jest.fn()} />);
fireEvent.click(screen.getByLabelText('Time'));
fireEvent.click(screen.getByText('Cancel'));
expect(await screen.findByText('Constraints not satisfied')).toBeInTheDocument();
});
it('should render error message returned from validator', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: jest.fn() });
const validator = (val: string) => (!val ? 'Please enter time' : '');
render(<TimePicker required label="Time" value="" onChange={jest.fn()} validator={validator} />);
fireEvent.blur(screen.getByLabelText('Time'));
expect(await screen.findByText('Please enter time')).toBeInTheDocument();
});

it('should render error message returned from validator', async () => {
const validator = (val: string) => (!val ? 'Please enter time' : '');
render(<TimePicker required label="Time" value="" onChange={jest.fn()} validator={validator} />);
fireEvent.click(screen.getByLabelText('Time'));
fireEvent.click(screen.getByText('Cancel'));
expect(await screen.findByText('Please enter time')).toBeInTheDocument();
it('should render error message if time is out of range', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: jest.fn() });
render(<TimePicker label="Time" value="" onChange={jest.fn()} />);
fireEvent.change(screen.getByLabelText('Time'), { target: { value: '78 : 78 AM' } });
fireEvent.blur(screen.getByLabelText('Time'));
expect(await screen.findByText('Time must be within the valid range of 12:00 AM to 11:59 PM')).toBeInTheDocument();
});
});
});
30 changes: 26 additions & 4 deletions packages/core/src/components/TimePicker/TimePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,50 @@
import { WithStyle, useCombinedRefs } from '@medly-components/utils';
import type { FC } from 'react';
import { forwardRef, memo, useRef } from 'react';
import { forwardRef, memo, useRef, useState } from 'react';
import { TimePickerWrapper } from './TimePicker.styled';
import TimePickerPopup from './TimePickerPopup';
import TimePickerTextField from './TimePickerTextField';
import { TimePickerProps } from './types';

const Component: FC<TimePickerProps> = memo(
forwardRef((props, ref) => {
const [textFieldKey, setTextfieldKey] = useState(0);
const isMobile = navigator?.userAgent?.indexOf('Mobi') > -1;
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useCombinedRefs<HTMLInputElement>(ref, useRef(null));
const id = props.id || props.label?.toLowerCase().replace(/\s/g, '') || 'medly-timepicker';
const { value, onChange, disabled, className, fullWidth, minWidth, maxWidth, popoverDistance, popoverPlacement, ...restProps } =
props;

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value);
const handleReset = () => {
setTextfieldKey(key => key + 1);
onChange('');
};

return (
<TimePickerWrapper className={className} fullWidth={fullWidth} minWidth={minWidth} maxWidth={maxWidth} interactionType="click">
<TimePickerTextField id={id} ref={inputRef} disabled={disabled} onChange={handleChange} value={value} {...restProps} />
<TimePickerWrapper
ref={wrapperRef}
className={className}
fullWidth={fullWidth}
minWidth={minWidth}
maxWidth={maxWidth}
interactionType="click"
>
<TimePickerTextField
id={id}
ref={inputRef}
disabled={disabled}
onChange={onChange}
value={value}
key={textFieldKey.toString()}
{...restProps}
/>
{!disabled && !isMobile && (
<TimePickerPopup
key={value.toString()}
value={value}
onChange={onChange}
onReset={handleReset}
popoverDistance={popoverDistance}
popoverPlacement={popoverPlacement}
/>
Expand Down
Loading

0 comments on commit bb23bd5

Please sign in to comment.