Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update timepicker styles #741

Merged
merged 4 commits into from
Jun 17, 2024
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
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
Loading