diff --git a/CHANGELOG.md b/CHANGELOG.md index d9883e4..16b0e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.17.1] - 2023-12-18 + - add option to enable title's tooltip + change up/down trend arrow colors to grey by [@shahramk](https://github.com/shahramk) +## [1.17.0] - 2023-12-14 + +### Added + +- DatePicker component by [@amit-y](https://github.com/amit-y) + ## [1.16.0] - 2023-08-09 ### Added diff --git a/README.md b/README.md index bf71ba5..0524cbf 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ A component that renders a progress bar. ### [FilterBar](src/components/filter-bar) Component that allows a user to filter options. +### [DatePicker](src/components/date-picker) +Component that displays a calendar to select a date + ## Utilities ### [timeRangeToNrql](src/utils/time-range-to-nrql/) diff --git a/package-lock.json b/package-lock.json index 06a72be..097ab7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@newrelic/nr-labs-components", - "version": "1.16.0", + "version": "1.17.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@newrelic/nr-labs-components", - "version": "1.16.0", + "version": "1.17.0", "license": "Apache-2.0", "dependencies": { "dayjs": "^1.11.7" diff --git a/package.json b/package.json index 8949af4..3ccea2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@newrelic/nr-labs-components", - "version": "1.16.0", + "version": "1.17.0", "description": "New Relic Labs components", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/src/components/date-picker/README.md b/src/components/date-picker/README.md new file mode 100644 index 0000000..64d51ae --- /dev/null +++ b/src/components/date-picker/README.md @@ -0,0 +1,26 @@ +# DatePicker + +The `DatePicker` component displays a calendar to select a date. + +## Usage + +To use the component, simply import and use: + +```jsx +import React, { useState } from 'react'; +import { DatePicker } from '@newrelic/nr-labs-components'; + +function MyComponent() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + return ( +
+ +
+ ); +} +``` +### Props + +- date (date): The default date +- onChange (function): A function that is called when the user selects a date diff --git a/src/components/date-picker/index.js b/src/components/date-picker/index.js new file mode 100644 index 0000000..be014f6 --- /dev/null +++ b/src/components/date-picker/index.js @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Icon, Popover, PopoverTrigger, PopoverBody, TextField } from 'nr1'; + +import { + formattedDateField, + formattedMonthYear, + firstDayOfMonth, + lastDateInMonth, + extractDateParts, + selectedDate, + daysOfWeek, + isSelectableDate, +} from './utils'; + +const DAYS_OF_WEEK = daysOfWeek(); + +import styles from './styles.scss'; + +const DatePicker = ({ date, onChange, validFrom }) => { + const [opened, setOpened] = useState(false); + const [current, setCurrent] = useState(extractDateParts(new Date())); + + useEffect(() => { + if (!date || !(date instanceof Date)) return; + setCurrent(extractDateParts(date)); + }, [date]); + + const prevMonth = () => { + const prevMo = new Date(current.yr, current.mo - 1); + setCurrent(extractDateParts(prevMo)); + }; + + const nextMonth = () => { + const nextMo = new Date(current.yr, current.mo + 1); + setCurrent(extractDateParts(nextMo)); + }; + + const isDateInCurrentMonth = (d = new Date()) => + d.getFullYear() === current.yr && d.getMonth() === current.mo; + + const clickHandler = (dt) => { + if (!isSelectableDate(current, dt + 1, validFrom) || !onChange) return; + + const d = date instanceof Date ? new Date(date.getTime()) : new Date(); + d.setFullYear(current.yr); + d.setMonth(current.mo); + d.setDate(dt + 1); + onChange(d); + + setOpened(false); + }; + + const changeHandler = (_, o) => setOpened(o); + + return ( + + + + + +
+
+ +
+
+ {formattedMonthYear(new Date(current.yr, current.mo))} +
+
+ +
+ {DAYS_OF_WEEK.map(({ long, short }) => ( +
+ {short} +
+ ))} + {Array.from({ length: firstDayOfMonth(current) }, (_, i) => ( +
+ ))} + {Array.from({ length: lastDateInMonth(current) }, (_, i) => ( +
clickHandler(i)} + > + {i + 1} +
+ ))} +
+ + + ); +}; + +DatePicker.propTypes = { + date: PropTypes.instanceOf(Date), + validFrom: PropTypes.instanceOf(Date), + onChange: PropTypes.func, +}; + +export default DatePicker; diff --git a/src/components/date-picker/styles.scss b/src/components/date-picker/styles.scss new file mode 100644 index 0000000..ae4523c --- /dev/null +++ b/src/components/date-picker/styles.scss @@ -0,0 +1,59 @@ +.calendar { + padding: 20px; + display: grid; + grid-template-columns: repeat(7, 1fr); +} + +.cell { + display: inline-block; + width: 32px; + height: 30px; + line-height: 30px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: #293338; + user-select: none; + + &.day { + font-size: 11px; + } + + &.mo-yr { + font-size: 14px; + grid-column: 2 / 7; + white-space: nowrap; + justify-self: center; + width: 100%; + } + + &.prev, + &.next, + &.date { + cursor: pointer; + } + + &.selected { + border-radius: 3px; + background-color: #293338; + color: #fafbfb; + } + + &.disabled { + color: #9ea5a9; + font-weight: 400; + cursor: auto; + } + + &.date { + &:not(.selected):not(.disabled) { + &:hover { + color: #0c74df; + } + } + } + + abbr { + text-decoration: none; + } +} diff --git a/src/components/date-picker/utils.js b/src/components/date-picker/utils.js new file mode 100644 index 0000000..696ed7b --- /dev/null +++ b/src/components/date-picker/utils.js @@ -0,0 +1,87 @@ +const dateFieldFormatter = new Intl.DateTimeFormat('default', { + year: 'numeric', + month: 'short', + day: 'numeric', +}); + +const formattedDateField = (dt) => + dt && dt instanceof Date ? dateFieldFormatter.format(dt) : ''; + +const monthYearFormatter = new Intl.DateTimeFormat('default', { + year: 'numeric', + month: 'long', +}); + +const formattedMonthYear = (dt) => + dt && dt instanceof Date ? monthYearFormatter.format(dt) : ''; + +const firstDayOfMonth = (d) => new Date(d.yr, d.mo).getDay(); + +const lastDateInMonth = (d) => new Date(d.yr, d.mo + 1, 0).getDate(); + +const extractDateParts = (d) => ({ + yr: d.getFullYear(), + mo: d.getMonth(), + dt: d.getDate(), +}); + +const afterToday = (cur, d) => { + const today = new Date(); + return ( + cur.yr === today.getFullYear() && + cur.mo === today.getMonth() && + d > today.getDate() + ); +}; + +const isSelectableDate = (cur, d, validFrom) => { + let isValid = true; + if (validFrom && validFrom instanceof Date) { + const validDate = new Date( + validFrom.getFullYear(), + validFrom.getMonth(), + validFrom.getDate() + ); + const curDt = new Date(cur.yr, cur.mo, d); + isValid = curDt >= validDate; + } + return isValid && !afterToday(cur, d); +}; + +const selectedDate = (index, cur, dt) => { + if (!dt || !(dt instanceof Date)) return false; + return ( + dt.getFullYear() === cur.yr && + dt.getMonth() === cur.mo && + dt.getDate() === index + 1 + ); +}; + +const daysOfWeek = () => { + const now = Date.now(); + const millisecondsInDay = 24 * 60 * 60 * 1000; + const startDayInMs = now - new Date().getDay() * millisecondsInDay; + const formats = ['long', 'short']; + const formatters = formats.map( + (fmt) => new Intl.DateTimeFormat('default', { weekday: fmt }) + ); + + return Array.from({ length: 7 }).map((_, i) => { + const d = new Date(startDayInMs + i * millisecondsInDay); + return formats.reduce( + (acc, fmt, idx) => ({ ...acc, [fmt]: formatters[idx].format(d) }), + {} + ); + }); +}; + +export { + formattedDateField, + formattedMonthYear, + firstDayOfMonth, + lastDateInMonth, + extractDateParts, + selectedDate, + daysOfWeek, + isSelectableDate, +}; diff --git a/src/components/index.js b/src/components/index.js index 00c1634..312e90f 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -8,3 +8,4 @@ export { default as StatusIcon } from './status-icon'; export { default as StatusIconsLayout } from './status-icons-layout'; export { default as ProgressBar } from './progress-bar'; export { default as FilterBar } from './filter-bar'; +export { default as DatePicker } from './date-picker';