diff --git a/package-lock.json b/package-lock.json index ab5bf6c11a..c198d240da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29041,8 +29041,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" }, @@ -41134,11 +41135,13 @@ "@instructure/ui-testable": "9.1.0", "@instructure/ui-text-input": "9.1.0", "@instructure/ui-utils": "9.1.0", + "moment-timezone": "^0.5.45", "prop-types": "^15.8.1" }, "devDependencies": { "@instructure/ui-axe-check": "9.1.0", "@instructure/ui-babel-preset": "9.1.0", + "@instructure/ui-buttons": "9.1.0", "@instructure/ui-scripts": "9.1.0", "@instructure/ui-test-utils": "9.1.0", "@testing-library/jest-dom": "^6.4.5", 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 9b2917197b..26427328ff 100644 --- a/packages/ui-date-input/package.json +++ b/packages/ui-date-input/package.json @@ -25,6 +25,7 @@ "devDependencies": { "@instructure/ui-axe-check": "9.1.0", "@instructure/ui-babel-preset": "9.1.0", + "@instructure/ui-buttons": "9.1.0", "@instructure/ui-scripts": "9.1.0", "@instructure/ui-test-utils": "9.1.0", "@testing-library/jest-dom": "^6.4.5", @@ -47,6 +48,7 @@ "@instructure/ui-testable": "9.1.0", "@instructure/ui-text-input": "9.1.0", "@instructure/ui-utils": "9.1.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..474a4f40bd 100644 --- a/packages/ui-date-input/src/DateInput/README.md +++ b/packages/ui-date-input/src/DateInput/README.md @@ -2,6 +2,8 @@ describes: DateInput --- +> **Important:** You can now use are updated version [`DateInput2`](/#DateInput2) which is easier to configure for devs, has a better UX for end users, better accessibility features and a year picker. In our next major update (v10) this component will be deprecated and renamed. + The `DateInput` component provides a visual interface for inputting date data. ### Default config 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..7cded5067a --- /dev/null +++ b/packages/ui-date-input/src/DateInput2/README.md @@ -0,0 +1,205 @@ +--- +describes: DateInput +--- + +This component is an updated version of [`DateInput`](/#DateInput) that's easier to configure for developers and use for end users. It has better accessibility and a year picker option. We recommend using this one instead and in our next major release (v10) it will replace `DateInput`. + +### 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() + ``` + +### Inside modal + +```js +--- +type: example +--- + +class Example extends React.Component { + constructor (props) { + super(props) + + this.state = { + open: false, + value: '', + } + } + + handleButtonClick = () => { + this.setState(function (state) { + return { open: !state.open } + }) + }; + + handleFormSubmit = e => { + e.preventDefault() + console.log('form submitted') + this.setState(state => ({ open: false })) + } + + renderCloseButton () { + return ( + + ) + } + + render () { + return ( +
+ + { this.setState({ open: false }) }} + onSubmit={this.handleFormSubmit} + size="auto" + label="Modal Dialog: Hello World" + shouldCloseOnDocumentClick + > + + {this.renderCloseButton()} + Hello World + + + this.setState({ 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..434f22e3cc --- /dev/null +++ b/packages/ui-date-input/src/DateInput2/index.tsx @@ -0,0 +1,235 @@ +/* + * 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 type { Moment } from '@instructure/ui-i18n' +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 { DateTime, 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' + +/** +--- +category: components +--- +**/ +const DateInput2 = ({ + renderLabel, + screenReaderLabels, + isRequired = false, + interaction = 'enabled', + value = '', + size = 'medium', + isInline = false, + 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: Moment, + e: SyntheticEvent + ) => { + setSelectedDate(dateString) + handleInputChange( + e, + `${momentDate.format('MMMM')} ${momentDate.format( + 'D' + )}, ${momentDate.format('YYYY')}` + ) + setShowPopover(false) + } + + const validateInput = (onlyRemoveError = false) => { + if ( + moment + .tz( + value ? value : '', + [ + DateTime.momentISOFormat, + 'llll', + 'LLLL', + 'lll', + 'LLL', + 'll', + 'LL', + 'l', + 'L' + ], + getLocale(), + true, + getTimezone() + ) + .isValid() || + value === '' + ) { + setSelectedDate(value || '') + setInputMessages(messages) + return + } + if (typeof invalidDateErrorMessage !== 'function' && !onlyRemoveError) { + setInputMessages([ + { + type: 'error', + text: invalidDateErrorMessage + } + ]) + } + } + + 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 + } + return DateTime.browserTimeZone() + } + + const handleBlur = (e: SyntheticEvent) => { + onBlur?.(e) + validateInput(false) + } + + 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..f103461587 --- /dev/null +++ b/packages/ui-date-input/src/DateInput2/props.ts @@ -0,0 +1,182 @@ +/* + * 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 data is invalid. + * If a string, shown to the user anytime the input is invalid. + * + * If a function, receives a single parameter: + * - *rawDateValue*: the string entered as a date by the user. + **/ + invalidDateErrorMessage?: string | ((rawDateValue: string) => FormMessage) + /** + * A standard language identifier. + * + * See [Moment.js](https://momentjs.com/timezone/docs/#/using-timezones/parsing-in-zone/) 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'