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