mirror of https://github.com/grafana/grafana
grafana/ui: Add DatePicker (#35742)
* Move DatePicker to grafana/ui * Export the pickers * Reuse TimePicker styles * Fix date formatting * Remove mockdate * Add release tags * Switch to input type='text' * Move DatePicker to pickers * Add mdx files * Update types * Update testspull/35829/head
parent
477d4197fb
commit
11335d6f6a
@ -0,0 +1,25 @@ |
||||
import { ArgsTable} from '@storybook/addon-docs/blocks'; |
||||
import { DatePicker } from './DatePicker'; |
||||
|
||||
# DatePicker |
||||
|
||||
A component with a calendar view for selecting a date. |
||||
|
||||
### Usage |
||||
```tsx |
||||
import React, { useState } from 'react'; |
||||
import { DatePicker, Button } from '@grafana/ui'; |
||||
|
||||
const [date, setDate] = useState<Date>(new Date()); |
||||
const [open, setOpen] = useState(false); |
||||
|
||||
return ( |
||||
<> |
||||
<Button onClick={() => setOpen(true)}>Show Calendar</Button> |
||||
<DatePicker isOpen={open} value={date} onChange={(newDate) => setDate(newDate)} onClose={() => setOpen(false)} /> |
||||
</> |
||||
) |
||||
``` |
||||
|
||||
### Props |
||||
<ArgsTable of={DatePicker} /> |
||||
@ -0,0 +1,28 @@ |
||||
import React, { useState } from 'react'; |
||||
import { DatePicker } from './DatePicker'; |
||||
import { Button } from '../../Button/Button'; |
||||
import mdx from './DatePicker.mdx'; |
||||
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory'; |
||||
|
||||
export default { |
||||
title: 'Pickers And Editors/DatePicker', |
||||
component: DatePicker, |
||||
decorators: [withCenteredStory], |
||||
parameters: { |
||||
docs: { |
||||
page: mdx, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export const Basic = () => { |
||||
const [date, setDate] = useState<Date>(new Date()); |
||||
const [open, setOpen] = useState(false); |
||||
|
||||
return ( |
||||
<> |
||||
<Button onClick={() => setOpen(true)}>Show Calendar</Button> |
||||
<DatePicker isOpen={open} value={date} onChange={(newDate) => setDate(newDate)} onClose={() => setOpen(false)} /> |
||||
</> |
||||
); |
||||
}; |
||||
@ -0,0 +1,54 @@ |
||||
import React from 'react'; |
||||
import { fireEvent, render, screen } from '@testing-library/react'; |
||||
import { DatePicker } from './DatePicker'; |
||||
|
||||
describe('DatePicker', () => { |
||||
it('does not render calendar when isOpen is false', () => { |
||||
render(<DatePicker isOpen={false} onChange={jest.fn()} onClose={jest.fn()} />); |
||||
|
||||
expect(screen.queryByTestId('date-picker')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders calendar when isOpen is true', () => { |
||||
render(<DatePicker isOpen={true} onChange={jest.fn()} onClose={jest.fn()} />); |
||||
|
||||
expect(screen.getByTestId('date-picker')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders calendar with default date', () => { |
||||
render(<DatePicker isOpen={true} onChange={jest.fn()} onClose={jest.fn()} value={new Date(1400000000000)} />); |
||||
|
||||
expect(screen.getByText('May 2014')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders calendar with date passed in', () => { |
||||
render(<DatePicker isOpen={true} value={new Date(1607431703363)} onChange={jest.fn()} onClose={jest.fn()} />); |
||||
|
||||
expect(screen.getByText('December 2020')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('calls onChange when date is selected', () => { |
||||
const onChange = jest.fn(); |
||||
|
||||
render(<DatePicker isOpen={true} onChange={onChange} onClose={jest.fn()} />); |
||||
|
||||
expect(onChange).not.toHaveBeenCalled(); |
||||
|
||||
// clicking the date
|
||||
fireEvent.click(screen.getByText('14')); |
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1); |
||||
}); |
||||
|
||||
it('calls onClose when outside of wrapper is clicked', () => { |
||||
const onClose = jest.fn(); |
||||
|
||||
render(<DatePicker isOpen={true} onChange={jest.fn()} onClose={onClose} />); |
||||
|
||||
expect(onClose).not.toHaveBeenCalled(); |
||||
|
||||
fireEvent.click(document); |
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,71 @@ |
||||
import React, { memo } from 'react'; |
||||
import Calendar from 'react-calendar/dist/entry.nostyle'; |
||||
import { css } from 'emotion'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '../../../themes'; |
||||
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper'; |
||||
import { Icon } from '../../Icon/Icon'; |
||||
import { getBodyStyles } from '../TimeRangePicker/TimePickerCalendar'; |
||||
|
||||
/** @public */ |
||||
export interface DatePickerProps { |
||||
isOpen?: boolean; |
||||
onClose: () => void; |
||||
onChange: (value: Date) => void; |
||||
value?: Date; |
||||
} |
||||
|
||||
/** @public */ |
||||
export const DatePicker = memo<DatePickerProps>((props) => { |
||||
const styles = useStyles2(getStyles); |
||||
const { isOpen, onClose } = props; |
||||
|
||||
if (!isOpen) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<ClickOutsideWrapper useCapture={true} includeButtonPress={false} onClick={onClose}> |
||||
<div className={styles.modal} data-testid="date-picker"> |
||||
<Body {...props} /> |
||||
</div> |
||||
</ClickOutsideWrapper> |
||||
); |
||||
}); |
||||
|
||||
DatePicker.displayName = 'DatePicker'; |
||||
|
||||
const Body = memo<DatePickerProps>(({ value, onChange }) => { |
||||
const styles = useStyles2(getBodyStyles); |
||||
|
||||
return ( |
||||
<Calendar |
||||
className={styles.body} |
||||
tileClassName={styles.title} |
||||
value={value || new Date()} |
||||
nextLabel={<Icon name="angle-right" />} |
||||
prevLabel={<Icon name="angle-left" />} |
||||
onChange={(ev) => { |
||||
if (!Array.isArray(ev)) { |
||||
onChange(ev); |
||||
} |
||||
}} |
||||
locale="en" |
||||
/> |
||||
); |
||||
}); |
||||
|
||||
Body.displayName = 'Body'; |
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
modal: css` |
||||
z-index: ${theme.zIndex.modal}; |
||||
position: absolute; |
||||
box-shadow: ${theme.shadows.z3}; |
||||
background-color: ${theme.colors.background.primary}; |
||||
border: 1px solid ${theme.colors.border.weak}; |
||||
border-radius: 2px 0 0 2px; |
||||
`,
|
||||
}; |
||||
}; |
||||
@ -0,0 +1,18 @@ |
||||
import { ArgsTable} from '@storybook/addon-docs/blocks'; |
||||
import { DatePickerWithInput } from './DatePickerWithInput'; |
||||
|
||||
# DatePickerWithInput |
||||
|
||||
An input with a calendar view, used to select a date. |
||||
|
||||
### Usage |
||||
```tsx |
||||
import React, { useState } from 'react'; |
||||
import { DatePickerWithInput } from '@grafana/ui'; |
||||
|
||||
const [date, setDate] = useState<Date | string>(new Date()); |
||||
return <DatePickerWithInput width={40} value={date} onChange={(newDate) => setDate(newDate)} />; |
||||
``` |
||||
|
||||
### Props |
||||
<ArgsTable of={DatePickerWithInput} /> |
||||
@ -0,0 +1,21 @@ |
||||
import React, { useState } from 'react'; |
||||
import { DatePickerWithInput } from './DatePickerWithInput'; |
||||
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory'; |
||||
import mdx from './DatePickerWithInput.mdx'; |
||||
|
||||
export default { |
||||
title: 'Pickers And Editors/DatePickerWithInput', |
||||
component: DatePickerWithInput, |
||||
decorators: [withCenteredStory], |
||||
parameters: { |
||||
docs: { |
||||
page: mdx, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export const Basic = () => { |
||||
const [date, setDate] = useState<Date | string>(new Date()); |
||||
|
||||
return <DatePickerWithInput width={40} value={date} onChange={(newDate) => setDate(newDate)} />; |
||||
}; |
||||
@ -0,0 +1,66 @@ |
||||
import React from 'react'; |
||||
import { fireEvent, render, screen } from '@testing-library/react'; |
||||
import { DatePickerWithInput } from './DatePickerWithInput'; |
||||
import { dateTimeFormat } from '@grafana/data'; |
||||
|
||||
describe('DatePickerWithInput', () => { |
||||
it('renders date input', () => { |
||||
render(<DatePickerWithInput onChange={jest.fn()} value={new Date(1400000000000)} />); |
||||
|
||||
expect(screen.getByDisplayValue(dateTimeFormat(1400000000000, { format: 'L' }))).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders date input with date passed in', () => { |
||||
render(<DatePickerWithInput value={new Date(1607431703363)} onChange={jest.fn()} />); |
||||
|
||||
expect(screen.getByDisplayValue(dateTimeFormat(1607431703363, { format: 'L' }))).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('does not render calendar', () => { |
||||
render(<DatePickerWithInput onChange={jest.fn()} />); |
||||
|
||||
expect(screen.queryByTestId('date-picker')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
describe('input is clicked', () => { |
||||
it('renders input', () => { |
||||
render(<DatePickerWithInput onChange={jest.fn()} />); |
||||
|
||||
fireEvent.click(screen.getByPlaceholderText('Date')); |
||||
|
||||
expect(screen.getByPlaceholderText('Date')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders calendar', () => { |
||||
render(<DatePickerWithInput onChange={jest.fn()} />); |
||||
|
||||
fireEvent.click(screen.getByPlaceholderText('Date')); |
||||
|
||||
expect(screen.queryByTestId('date-picker')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
it('calls onChange after date is selected', () => { |
||||
const onChange = jest.fn(); |
||||
render(<DatePickerWithInput onChange={onChange} />); |
||||
|
||||
// open calendar and select a date
|
||||
fireEvent.click(screen.getByPlaceholderText('Date')); |
||||
fireEvent.click(screen.getByText('14')); |
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1); |
||||
}); |
||||
|
||||
it('closes calendar after outside wrapper is clicked', () => { |
||||
render(<DatePickerWithInput onChange={jest.fn()} />); |
||||
|
||||
// open calendar and click outside
|
||||
fireEvent.click(screen.getByPlaceholderText('Date')); |
||||
|
||||
expect(screen.getByTestId('date-picker')).toBeInTheDocument(); |
||||
|
||||
fireEvent.click(document); |
||||
|
||||
expect(screen.queryByTestId('date-picker')).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,75 @@ |
||||
import React, { ChangeEvent } from 'react'; |
||||
import { css } from '@emotion/css'; |
||||
import { dateTimeFormat } from '@grafana/data'; |
||||
import { DatePicker } from '../DatePicker/DatePicker'; |
||||
import { Props as InputProps, Input } from '../../Input/Input'; |
||||
import { useStyles } from '../../../themes'; |
||||
|
||||
export const formatDate = (date: Date | string) => dateTimeFormat(date, { format: 'L' }); |
||||
|
||||
/** @public */ |
||||
export interface DatePickerWithInputProps extends Omit<InputProps, 'ref' | 'value' | 'onChange'> { |
||||
value?: Date | string; |
||||
onChange: (value: Date | string) => void; |
||||
/** Hide the calendar when date is selected */ |
||||
closeOnSelect?: boolean; |
||||
placeholder?: string; |
||||
} |
||||
|
||||
/** @public */ |
||||
export const DatePickerWithInput = ({ |
||||
value, |
||||
onChange, |
||||
closeOnSelect, |
||||
placeholder = 'Date', |
||||
...rest |
||||
}: DatePickerWithInputProps) => { |
||||
const [open, setOpen] = React.useState(false); |
||||
const styles = useStyles(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.container}> |
||||
<Input |
||||
type="text" |
||||
autoComplete={'off'} |
||||
placeholder={placeholder} |
||||
value={value ? formatDate(value) : value} |
||||
onClick={() => setOpen(true)} |
||||
onChange={(ev: ChangeEvent<HTMLInputElement>) => { |
||||
// Allow resetting the date
|
||||
if (ev.target.value === '') { |
||||
onChange(''); |
||||
} |
||||
}} |
||||
className={styles.input} |
||||
{...rest} |
||||
/> |
||||
<DatePicker |
||||
isOpen={open} |
||||
value={value && typeof value !== 'string' ? value : new Date()} |
||||
onChange={(ev) => { |
||||
onChange(ev); |
||||
if (closeOnSelect) { |
||||
setOpen(false); |
||||
} |
||||
}} |
||||
onClose={() => setOpen(false)} |
||||
/> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = () => { |
||||
return { |
||||
container: css` |
||||
position: relative; |
||||
`,
|
||||
input: css` |
||||
/* hides the native Calendar picker icon given when using type=date */ |
||||
input[type='date']::-webkit-inner-spin-button, |
||||
input[type='date']::-webkit-calendar-picker-indicator { |
||||
display: none; |
||||
-webkit-appearance: none; |
||||
`,
|
||||
}; |
||||
}; |
||||
Loading…
Reference in new issue