diff --git a/package-lock.json b/package-lock.json index b8f815b1d3..fd47e86981 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28119,8 +28119,9 @@ } }, "node_modules/moment-timezone": { - "version": "0.5.43", - "license": "MIT", + "version": "0.5.45", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz", + "integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==", "dependencies": { "moment": "^2.29.4" }, @@ -42295,11 +42296,13 @@ "@instructure/ui-testable": "9.2.0", "@instructure/ui-text-input": "9.2.0", "@instructure/ui-utils": "9.2.0", + "moment-timezone": "^0.5.45", "prop-types": "^15.8.1" }, "devDependencies": { "@instructure/ui-axe-check": "9.2.0", "@instructure/ui-babel-preset": "9.2.0", + "@instructure/ui-buttons": "9.2.0", "@instructure/ui-scripts": "9.2.0", "@instructure/ui-test-utils": "9.2.0", "@testing-library/jest-dom": "^6.4.6", diff --git a/packages/__docs__/components.js b/packages/__docs__/components.js index 68ca633719..38149739e6 100644 --- a/packages/__docs__/components.js +++ b/packages/__docs__/components.js @@ -55,7 +55,7 @@ export { Expandable } from '@instructure/ui-expandable' export { Focusable } from '@instructure/ui-focusable' export { Img } from '@instructure/ui-img' export { NumberInput } from '@instructure/ui-number-input' -export { DateInput } from '@instructure/ui-date-input' +export { DateInput, DateInput2 } from '@instructure/ui-date-input' export { DateTimeInput } from '@instructure/ui-date-time-input' export { Pill } from '@instructure/ui-pill' export { TextInput } from '@instructure/ui-text-input' @@ -69,10 +69,7 @@ export { } from '@instructure/ui-form-field' export { Table } from '@instructure/ui-table' export { TruncateText } from '@instructure/ui-truncate-text' -export { - ApplyLocale, - TextDirectionContext -} from '@instructure/ui-i18n' +export { ApplyLocale, TextDirectionContext } from '@instructure/ui-i18n' export { MetricGroup, Metric } from '@instructure/ui-metric' export { Modal } from '@instructure/ui-modal' export { Transition } from '@instructure/ui-motion' diff --git a/packages/ui-date-input/package.json b/packages/ui-date-input/package.json index a9cd4535c2..13012ab5fa 100644 --- a/packages/ui-date-input/package.json +++ b/packages/ui-date-input/package.json @@ -28,6 +28,7 @@ "@instructure/ui-scripts": "9.2.0", "@instructure/ui-test-utils": "9.2.0", "@testing-library/jest-dom": "^6.4.6", + "@instructure/ui-buttons": "9.2.0", "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.2", "vitest": "^2.0.2" @@ -48,6 +49,7 @@ "@instructure/ui-testable": "9.2.0", "@instructure/ui-text-input": "9.2.0", "@instructure/ui-utils": "9.2.0", + "moment-timezone": "^0.5.45", "prop-types": "^15.8.1" }, "peerDependencies": { diff --git a/packages/ui-date-input/src/DateInput/README.md b/packages/ui-date-input/src/DateInput/README.md index 1c011f54de..409ab327f3 100644 --- a/packages/ui-date-input/src/DateInput/README.md +++ b/packages/ui-date-input/src/DateInput/README.md @@ -2,38 +2,9 @@ describes: DateInput --- -The `DateInput` component provides a visual interface for inputting date data. - -### Default config - -For ease of use in most situations, the `DateInput` component provides a default -configuration. The default configuration can be overridden by providing props -to the `DateInput` component. - -```javascript ---- -type: example ---- -class Example extends React.Component { - state = { value: '' } +> **Important:** You can now use are updated version [`DateInput2`](/#DateInput2) which is easier to configure for developers, has a better UX, better accessibility features and a year picker. We recommend using that instead of `DateInput` which will be deprecated in the future. - render () { - return ( - this.setState({value:value.value})} - invalidDateErrorMessage="Invalid date" - /> - ) - } -} - -render() -``` +The `DateInput` component provides a visual interface for inputting date data. ### Composing a DateInput in your Application diff --git a/packages/ui-date-input/src/DateInput/index.tsx b/packages/ui-date-input/src/DateInput/index.tsx index 48736e7031..272c854e7d 100644 --- a/packages/ui-date-input/src/DateInput/index.tsx +++ b/packages/ui-date-input/src/DateInput/index.tsx @@ -59,8 +59,6 @@ import type { FormMessage } from '@instructure/ui-form-field' --- category: components --- -The `DateInput` component provides a visual interface for inputting date data. -See **/ @withStyle(generateStyle, null) @testable() diff --git a/packages/ui-date-input/src/DateInput/props.ts b/packages/ui-date-input/src/DateInput/props.ts index 526b3e4878..d39f16119b 100644 --- a/packages/ui-date-input/src/DateInput/props.ts +++ b/packages/ui-date-input/src/DateInput/props.ts @@ -250,6 +250,24 @@ type DateInputOwnProps = { * property or a context property. **/ timezone?: string + + /** + * If set, years can be picked from a dropdown. + * It accepts an object. + * screenReaderLabel: string // e.g.: i18n("pick a year") + * + * onRequestYearChange?:(e: React.MouseEvent,requestedYear: number): void // if set, on year change, only this will be called and no internal change will take place + * + * startYear: number // e.g.: 2001, sets the start year of the selectable list + * + * endYear: number // e.g.: 2030, sets the end year of the selectable list + */ + withYearPicker?: { + screenReaderLabel: string + onRequestYearChange?: (e: any, requestedYear: number) => void + startYear: number + endYear: number + } } type PropKeys = keyof DateInputOwnProps @@ -308,7 +326,8 @@ const propTypes: PropValidators = { PropTypes.string ]), locale: PropTypes.string, - timezone: PropTypes.string + timezone: PropTypes.string, + withYearPicker: PropTypes.object } const allowedProps: AllowedPropKeys = [ diff --git a/packages/ui-date-input/src/DateInput2/README.md b/packages/ui-date-input/src/DateInput2/README.md new file mode 100644 index 0000000000..0d0554c0d0 --- /dev/null +++ b/packages/ui-date-input/src/DateInput2/README.md @@ -0,0 +1,114 @@ +--- +describes: DateInput +--- + +This component is an updated version of [`DateInput`](/#DateInput) that's easier to configure for developers, has a better UX, better accessibility features and a year picker. We recommend using this instead of `DateInput` which will be deprecated in the future. + +### Minimal config + +- ```js + class Example extends React.Component { + state = { value: '' } + + render() { + return ( + this.setState({ value })} + invalidDateErrorMessage="Invalid date" + /> + ) + } + } + + render() + ``` + +- ```js + const Example = () => { + const [value, setValue] = useState('') + return ( + setValue(value)} + invalidDateErrorMessage="Invalid date" + /> + ) + } + + render() + ``` + +### With year picker + +- ```js + class Example extends React.Component { + state = { value: '' } + + render() { + return ( + this.setState({ value })} + invalidDateErrorMessage="Invalid date" + withYearPicker={{ + screenReaderLabel: 'Year picker', + startYear: 1999, + endYear: 2024 + }} + /> + ) + } + } + + render() + ``` + +- ```js + const Example = () => { + const [value, setValue] = useState('') + + return ( + setValue(value)} + invalidDateErrorMessage="Invalid date" + withYearPicker={{ + screenReaderLabel: 'Year picker', + startYear: 1999, + endYear: 2024 + }} + /> + ) + } + + render() + ``` diff --git a/packages/ui-date-input/src/DateInput2/index.tsx b/packages/ui-date-input/src/DateInput2/index.tsx new file mode 100644 index 0000000000..a311a28b55 --- /dev/null +++ b/packages/ui-date-input/src/DateInput2/index.tsx @@ -0,0 +1,264 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** @jsx jsx */ +import { useState, useEffect, useContext } from 'react' +import type { SyntheticEvent } from 'react' +import moment from 'moment-timezone' +import { Calendar } from '@instructure/ui-calendar' +import { IconButton } from '@instructure/ui-buttons' +import { + IconCalendarMonthLine, + IconArrowOpenEndSolid, + IconArrowOpenStartSolid +} from '@instructure/ui-icons' +import { Popover } from '@instructure/ui-popover' +import { TextInput } from '@instructure/ui-text-input' +import { passthroughProps } from '@instructure/ui-react-utils' + +import { ApplyLocaleContext, Locale } from '@instructure/ui-i18n' +import { jsx } from '@instructure/emotion' + +import { propTypes } from './props' +import type { DateInput2Props } from './props' +import type { FormMessage } from '@instructure/ui-form-field' + +function isValidDate(dateString: string): boolean { + return !isNaN(new Date(dateString).getTime()) +} + +function isValidMomentDate( + dateString: string, + locale: string, + timezone: string +): boolean { + return moment + .tz( + dateString, + [ + moment.ISO_8601, + 'llll', + 'LLLL', + 'lll', + 'LLL', + 'll', + 'LL', + 'l', + 'L' + ], + locale, + true, + timezone + ) + .isValid() +} + +/** +--- +category: components +--- +**/ +const DateInput2 = ({ + renderLabel, + screenReaderLabels, + isRequired = false, + interaction = 'enabled', + size = 'medium', + isInline = false, + value, + messages, + width, + onChange, + onBlur, + withYearPicker, + invalidDateErrorMessage, + locale, + timezone, + placeholder, + ...rest +}: DateInput2Props) => { + const [selectedDate, setSelectedDate] = useState('') + const [inputMessages, setInputMessages] = useState( + messages || [] + ) + const [showPopover, setShowPopover] = useState(false) + const localeContext = useContext(ApplyLocaleContext) + + useEffect(() => { + validateInput(true) + }, [value]) + + useEffect(() => { + setInputMessages(messages || []) + }, [messages]) + + const handleInputChange = (e: SyntheticEvent, value: string) => { + onChange?.(e, value) + } + + const handleDateSelected = ( + dateString: string, + _momentDate: any, // real type is Moment but used `any` to avoid importing the moment lib + e: SyntheticEvent + ) => { + const formattedDate = new Date(dateString).toLocaleDateString(getLocale(), { + month: 'long', + year: 'numeric', + day: 'numeric', + timeZone: getTimezone() + }) + handleInputChange(e, formattedDate) + setShowPopover(false) + } + + const validateInput = (onlyRemoveError = false): boolean => { + // TODO `isValidDate` and `isValidMomentDate` basically have the same functionality but the latter is a bit more strict (e.g.: `33` is only valid in `isValidMomentDate`) + // in the future we should get rid of moment but currently Calendar is using it for validation too so we can only remove it simultaneously + // otherwise DateInput could pass invalid dates to Calendar and break it + if ( + (isValidDate(value || '') && + isValidMomentDate(value || '', getLocale(), getTimezone())) || + value === '' + ) { + setSelectedDate(value || '') + setInputMessages(messages || []) + return true + } + if (!onlyRemoveError) { + setInputMessages([ + { + type: 'error', + text: invalidDateErrorMessage || '', + } + ]) + } + return false + } + + const getLocale = () => { + if (locale) { + return locale + } else if (localeContext.locale) { + return localeContext.locale + } + return Locale.browserLocale() + } + + const getTimezone = () => { + if (timezone) { + return timezone + } else if (localeContext.timezone) { + return localeContext.timezone + } + // default to the system's timezone + return Intl.DateTimeFormat().resolvedOptions().timeZone + } + + const handleBlur = (e: SyntheticEvent) => { + onBlur?.(e) + const isInputValid = validateInput(false) + if (isInputValid && value) { + const formattedDate = new Date(value).toLocaleDateString(getLocale(), { + month: 'long', + year: 'numeric', + day: 'numeric', + timeZone: getTimezone() + }) + handleInputChange(e, formattedDate) + } + } + + return ( + + + + } + isShowingContent={showPopover} + onShowContent={() => setShowPopover(true)} + onHideContent={() => setShowPopover(false)} + on="click" + shouldContainFocus + shouldReturnFocus + shouldCloseOnDocumentClick + > + } + screenReaderLabel={screenReaderLabels.nextMonthButton} + /> + } + renderPrevMonthButton={ + } + screenReaderLabel={screenReaderLabels.prevMonthButton} + /> + } + /> + + } + /> + ) +} + +DateInput2.propTypes = propTypes + +export default DateInput2 +export { DateInput2 } diff --git a/packages/ui-date-input/src/DateInput2/props.ts b/packages/ui-date-input/src/DateInput2/props.ts new file mode 100644 index 0000000000..543eec034b --- /dev/null +++ b/packages/ui-date-input/src/DateInput2/props.ts @@ -0,0 +1,178 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import PropTypes from 'prop-types' +import type { SyntheticEvent } from 'react' + +import { controllable } from '@instructure/ui-prop-types' +import { FormPropTypes } from '@instructure/ui-form-field' +import type { FormMessage } from '@instructure/ui-form-field' +import type { Renderable, PropValidators } from '@instructure/shared-types' + +type DateInput2Props = { + /** + * Specifies the input label. + */ + renderLabel: Renderable + screenReaderLabels: { + calendarIcon: string + prevMonthButton: string + nextMonthButton: string + } + /** + * Specifies the input value. + */ + value?: string // TODO: controllable(PropTypes.string) + /** + * Specifies the input size. + */ + size?: 'small' | 'medium' | 'large' + /** + * Html placeholder text to display when the input has no value. This should + * be hint text, not a label replacement. + */ + placeholder?: string + /** + * Callback fired when the input changes. + */ + onChange?: (event: React.SyntheticEvent, value: string) => void + /** + * Callback executed when the input fires a blur event. + */ + onBlur?: (event: React.SyntheticEvent) => void + /** + * Specifies if interaction with the input is enabled, disabled, or readonly. + * When "disabled", the input changes visibly to indicate that it cannot + * receive user interactions. When "readonly" the input still cannot receive + * user interactions but it keeps the same styles as if it were enabled. + */ + interaction?: 'enabled' | 'disabled' | 'readonly' + /** + * Specifies if the input is required. + */ + isRequired?: boolean + /** + * Controls whether the input is rendered inline with other elements or if it + * is rendered as a block level element. + */ + isInline?: boolean + /** + * Specifies the width of the input. + */ + width?: string + /** + * Displays messages and validation for the input. It should be an object + * with the following shape: + * `{ + * text: PropTypes.node, + * type: PropTypes.oneOf(['error', 'hint', 'success', 'screenreader-only']) + * }` + */ + messages?: FormMessage[] + /** + * Callback fired requesting the calendar be shown. + */ + onRequestShowCalendar?: (event: SyntheticEvent) => void + /** + * Callback fired requesting the calendar be hidden. + */ + onRequestHideCalendar?: (event: SyntheticEvent) => void + /** + * The message shown to the user when the date is invalid. + **/ + invalidDateErrorMessage?: string + /** + * A standard language identifier. + * + * See [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#locales) for + * more details. + * + * This property can also be set via a context property and if both are set + * then the component property takes precedence over the context property. + * + * The web browser's locale will be used if no value is set via a component + * property or a context property. + **/ + locale?: string + /** + * A timezone identifier in the format: *Area/Location* + * + * See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the list + * of possible options. + * + * This property can also be set via a context property and if both are set + * then the component property takes precedence over the context property. + * + * The web browser's timezone will be used if no value is set via a component + * property or a context property. + **/ + timezone?: string + + /** + * If set, years can be picked from a dropdown. + * It accepts an object. + * screenReaderLabel: string // e.g.: i18n("pick a year") + * + * onRequestYearChange?:(e: React.MouseEvent,requestedYear: number): void // if set, on year change, only this will be called and no internal change will take place + * + * startYear: number // e.g.: 2001, sets the start year of the selectable list + * + * endYear: number // e.g.: 2030, sets the end year of the selectable list + */ + withYearPicker?: { + screenReaderLabel: string + onRequestYearChange?: (e: SyntheticEvent, requestedYear: number) => void + startYear: number + endYear: number + } +} + +type PropKeys = keyof DateInput2Props + +const propTypes: PropValidators = { + renderLabel: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, + screenReaderLabels: PropTypes.object.isRequired, + value: controllable(PropTypes.string), + size: PropTypes.oneOf(['small', 'medium', 'large']), + placeholder: PropTypes.string, + onChange: PropTypes.func, + onBlur: PropTypes.func, + interaction: PropTypes.oneOf(['enabled', 'disabled', 'readonly']), + isRequired: PropTypes.bool, + isInline: PropTypes.bool, + width: PropTypes.string, + messages: PropTypes.arrayOf(FormPropTypes.message), + onRequestShowCalendar: PropTypes.func, + onRequestHideCalendar: PropTypes.func, + invalidDateErrorMessage: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.string + ]), + locale: PropTypes.string, + timezone: PropTypes.string, + withYearPicker: PropTypes.object +} + +export type { DateInput2Props } +export { propTypes } diff --git a/packages/ui-date-input/src/index.ts b/packages/ui-date-input/src/index.ts index 78bad96454..cbfea3e729 100644 --- a/packages/ui-date-input/src/index.ts +++ b/packages/ui-date-input/src/index.ts @@ -23,4 +23,5 @@ */ export { DateInput } from './DateInput' +export { DateInput2 } from './DateInput2' export type { DateInputProps } from './DateInput/props' diff --git a/packages/ui-date-input/tsconfig.build.json b/packages/ui-date-input/tsconfig.build.json index 925c6de1fb..2c57876b7e 100644 --- a/packages/ui-date-input/tsconfig.build.json +++ b/packages/ui-date-input/tsconfig.build.json @@ -10,6 +10,9 @@ { "path": "../ui-babel-preset/tsconfig.build.json" }, + { + "path": "../ui-buttons/tsconfig.build.json" + }, { "path": "../ui-test-utils/tsconfig.build.json" }, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index bbe153ad5f..35ae2f0027 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -58,7 +58,7 @@ export { ColorContrast, ColorIndicator } from '@instructure/ui-color-picker' -export { DateInput } from '@instructure/ui-date-input' +export { DateInput, DateInput2 } from '@instructure/ui-date-input' export { DateTimeInput } from '@instructure/ui-date-time-input' export { Dialog } from '@instructure/ui-dialog' export { DrawerLayout, DrawerContent } from '@instructure/ui-drawer-layout'