Skip to content

Commit

Permalink
feat: add new TimePicker component (#738)
Browse files Browse the repository at this point in the history
* feat: add new TimePicker component

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

* docs: add TimePicker test to the docs

affects: @medly-components/core

* fix: lint issues

affects: @medly-components/theme

* fix: static code analysis error

affects: @medly-components/core
  • Loading branch information
gmukul01 authored Dec 31, 2023
1 parent d3889e1 commit 51744cd
Show file tree
Hide file tree
Showing 16 changed files with 1,802 additions and 0 deletions.
51 changes: 51 additions & 0 deletions packages/core/src/components/TimePicker/TimePicker.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { TimePicker } from './TimePicker';
import { Preview, Story, Meta, Props } from '@storybook/addon-docs/blocks';
import { boolean, select, text } from '@storybook/addon-knobs';
import { placements } from '../Popover/Popover.stories.tsx';
import { variants } from './TimePicker.stories';
import { useState } from 'react';

<Meta
title="Core"
component={TimePicker}
parameters={{
jest: ['TimePicker.test.tsx']
}}
/>

# TimePicker Component

The `TimePicker` is a controlled component that allows you to select a time through input text or using a dialog. This component is built on top of the `TextField` component. It accepts all the props of `TextField` component. This component will return the time in **24-hour format**.

This component will use the native dialog on the mobile devices.

## Usage

```tsx
import { TimePicker } from '@medly-components/core';

const Component = () => {
const [time, setTime] = useState('');
return <TimePicker label="Time" value={time} onChange={setTime} />;
};
```

<Preview withToolbar>
<Story name="TimePicker">
{() => {
const [time, setTime] = useState('');
return (
<TimePicker
id="dob"
value={time}
onChange={setTime}
disabled={boolean('Disabled', false)}
label={text('Label', 'Time')}
required={boolean('Required', false)}
variant={select('variant', variants, 'outlined')}
popoverPlacement={select('Popover placement', placements, 'bottom-start')}
/>
);
}}
</Story>
</Preview>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { TimePickerProps } from './types';

export const variants: Required<TimePickerProps>['variant'][] = ['outlined', 'filled', 'fusion'];
14 changes: 14 additions & 0 deletions packages/core/src/components/TimePicker/TimePicker.styled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AccessTimeIcon } from '@medly-components/icons';
import { InjectClassName } from '@medly-components/utils';
import styled from 'styled-components';

export const TimePickerWrapper = styled(InjectClassName)`
input[type='time']::-webkit-calendar-picker-indicator {
background: none;
display: none;
}
`;

export const TimeIcon = styled(AccessTimeIcon).attrs({ title: 'time-icon' })`
cursor: pointer;
`;
59 changes: 59 additions & 0 deletions packages/core/src/components/TimePicker/TimePicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { fireEvent, render, screen } 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'));
expect(container).toMatchSnapshot();
});

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' } });
expect(mockOnChange).toBeCalledWith('22:00');
});

it('should give the expected time on selecting time from dialog', () => {
const mockOnChange = jest.fn();
render(<TimePicker label="Time" value="" onChange={mockOnChange} />);
fireEvent.click(screen.getByTitle('time-icon'));
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');
});

it('should reset the values on clicking on cancel button', () => {
const mockOnChange = jest.fn();
render(<TimePicker label="Time" value="" onChange={mockOnChange} />);
fireEvent.click(screen.getByTitle('time-icon'));
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();
});

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.getByTitle('time-icon'));
expect(screen.queryByText('hour-arrow-down')).not.toBeInTheDocument();
});
});
49 changes: 49 additions & 0 deletions packages/core/src/components/TimePicker/TimePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { WithStyle } from '@medly-components/utils';
import type { FC } from 'react';
import { forwardRef, memo } from 'react';
import Popover from '../Popover';
import TextField from '../TextField';
import { TimeIcon, TimePickerWrapper } from './TimePicker.styled';
import TimePickerPopup from './TimePickerPopup';
import { TimePickerProps } from './types';

const Component: FC<TimePickerProps> = memo(
forwardRef((props, ref) => {
const { value, onChange, disabled, className, popoverPlacement, ...restProps } = props;
const id = props.id || props.label?.toLowerCase().replace(/\s/g, '') || 'medly-timepicker';
const isMobile = navigator?.userAgent?.indexOf('Mobi') > -1;

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value);

return (
<Popover className={className} interactionType="click">
<TimePickerWrapper>
<TextField
fullWidth
type="time"
suffix={TimeIcon}
id={id}
ref={ref}
disabled={disabled}
onChange={handleChange}
value={value}
{...restProps}
/>
</TimePickerWrapper>
{!disabled && !isMobile && <TimePickerPopup value={value} onChange={onChange} popoverPlacement={popoverPlacement} />}
</Popover>
);
})
);
Component.defaultProps = {
size: 'M',
label: 'Time',
variant: 'outlined',
disabled: false,
required: false,
fullWidth: false,
minWidth: '20rem',
showDecorators: true
};
Component.displayName = 'TimePicker';
export const TimePicker: FC<TimePickerProps> & WithStyle = Object.assign(Component, { Style: TimePickerWrapper });
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import styled from 'styled-components';
import Text from '../../Text';

export const TimePickerCard = styled('div')`
padding: 1.2rem;
background: ${({ theme }) => theme.timePicker.bgColor};
box-shadow: 0 0.2rem 0.8rem ${({ theme }) => theme.timePicker.shadowColor};
border-radius: ${({ theme }) => theme.timePicker.borderRadius};
width: max-content;
height: max-content;
`;

export const TimeLabels = styled('div')`
display: flex;
flex-direction: row;
& > * {
flex: 1;
user-select: none;
text-align: center;
}
& > *:nth-child(2) {
flex: 0.5;
}
`;

export const TimePickerWrapper = styled.div`
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: 1.8rem 0 2.4rem;
position: relative;
& > * {
flex: 1;
}
&::before,
&::after {
content: '';
position: absolute;
left: -1.2rem;
width: calc(100% + 2.4rem);
height: 0.1rem;
background-color: ${({ theme }) => theme.colors.grey[300]};
}
&::before {
top: 3.2rem;
}
&::after {
bottom: 3.2rem;
}
`;

export const TimePicker = styled.div`
display: flex;
flex-direction: column;
gap: 2.4rem;
align-items: center;
justify-content: center;
`;

export const TimeUList = styled.ul`
padding: 0;
margin: 0;
height: ${({ theme }) => theme.timePicker.selectedOption.lineHeight};
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;
}
& > li {
scroll-snap-align: center;
font-size: ${({ theme }) => theme.timePicker.selectedOption.fontSize};
font-weight: ${({ theme }) => theme.timePicker.selectedOption.fontWeight};
line-height: ${({ theme }) => theme.timePicker.selectedOption.lineHeight};
letter-spacing: ${({ theme }) => theme.timePicker.selectedOption.LetterSpacing};
color: ${({ theme }) => theme.timePicker.selectedOption.color};
}
`;

export const PeriodPicker = styled('div')`
position: relative;
max-height: 2.4rem;
`;

export const PeriodUList = styled('ul')`
padding: 0;
margin: 0;
gap: 2.4rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
user-select: none;
list-style: none;
transition: transform 200ms ease;
`;

export const PeriodListItem = styled('li')<{ isSelected: boolean }>`
font-size: ${({ isSelected, theme }) => theme.timePicker[isSelected ? 'selectedOption' : 'nonSelectedOption'].fontSize};
font-weight: ${({ isSelected, theme }) => theme.timePicker[isSelected ? 'selectedOption' : 'nonSelectedOption'].fontWeight};
line-height: ${({ isSelected, theme }) => theme.timePicker[isSelected ? 'selectedOption' : 'nonSelectedOption'].lineHeight};
letter-spacing: ${({ isSelected, theme }) => theme.timePicker[isSelected ? 'selectedOption' : 'nonSelectedOption'].LetterSpacing};
color: ${({ isSelected, theme }) => theme.timePicker[isSelected ? 'selectedOption' : 'nonSelectedOption'].color};
cursor: pointer;
transition: all 200ms ease;
`;

export const Colon = styled(Text).attrs({ children: ':' })`
flex: 0.5;
display: flex;
justify-content: flex-end;
user-select: none;
`;

export const TimePickerActions = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`;
Loading

0 comments on commit 51744cd

Please sign in to comment.