From 6487d7b8fdd5b678d7777f507b9f9e919941da4c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?=
Date: Tue, 17 Mar 2026 11:20:59 +0100
Subject: [PATCH 1/2] fix(docs): include components at different version stages
in versioned docs
The version map stored a single componentVersion per package per library
version, determined by the first import found in the export file. This
excluded components that haven't been migrated to v2 (e.g. DateInput2)
from v11_7 docs, because the export file also imports DateInput/v2 and
only that first match was recorded.
Changed to collect all component versions referenced in each export file
so packages with mixed v1/v2 components are correctly included.
---
packages/__docs__/buildScripts/DataTypes.mts | 2 +-
.../buildScripts/utils/buildVersionMap.mts | 40 +++++++++++--------
2 files changed, 24 insertions(+), 18 deletions(-)
diff --git a/packages/__docs__/buildScripts/DataTypes.mts b/packages/__docs__/buildScripts/DataTypes.mts
index c0cedeac3d..e919055762 100644
--- a/packages/__docs__/buildScripts/DataTypes.mts
+++ b/packages/__docs__/buildScripts/DataTypes.mts
@@ -146,7 +146,7 @@ type MainDocsData = {
type VersionMapEntry = {
exportLetter: string
- componentVersion: string
+ componentVersions: string[]
}
type VersionMap = {
diff --git a/packages/__docs__/buildScripts/utils/buildVersionMap.mts b/packages/__docs__/buildScripts/utils/buildVersionMap.mts
index 50168a9945..144ff60169 100644
--- a/packages/__docs__/buildScripts/utils/buildVersionMap.mts
+++ b/packages/__docs__/buildScripts/utils/buildVersionMap.mts
@@ -92,8 +92,8 @@ export async function buildVersionMap(
continue
}
- // Resolve the component version from the source export file
- const componentVersion = resolveComponentVersion(
+ // Resolve all component versions from the source export file
+ const componentVersions = resolveComponentVersions(
pkgDir,
exportLetter,
pkgShortName
@@ -104,7 +104,7 @@ export async function buildVersionMap(
}
mapping[libVersion][pkgShortName] = {
exportLetter,
- componentVersion
+ componentVersions
}
}
}
@@ -162,18 +162,20 @@ export function isDocIncludedInVersion(
return componentVersion === 'v1'
}
- return componentVersion === entry.componentVersion
+ return entry.componentVersions.includes(componentVersion)
}
/**
* Reads the source export file (e.g. src/exports/a.ts) and parses imports
- * to determine which component version directory it maps to (v1 or v2).
+ * to determine which component version directories it maps to (v1, v2, etc.).
+ * A single export file may reference multiple versions when a package contains
+ * components at different migration stages (e.g. DateInput/v2 + DateInput2/v1).
*/
-function resolveComponentVersion(
+function resolveComponentVersions(
pkgDir: string,
exportLetter: string,
pkgShortName: string
-): string {
+): string[] {
const exportFilePath = path.join(
pkgDir,
'src',
@@ -190,20 +192,24 @@ function resolveComponentVersion(
const content = fs.readFileSync(exportFilePath, 'utf-8')
- // Match patterns like:
+ // Match all patterns like:
// from '../ComponentName/v2'
// from '../ComponentName/v1/SubComponent'
- const versionMatch = content.match(
- /from\s+['"]\.\.\/[^/]+\/(v\d+)(?:\/|['"])/
- )
- if (versionMatch) {
- return versionMatch[1]
+ const versionRegex = /from\s+['"]\.\.\/[^/]+\/(v\d+)(?:\/|['"])/g
+ const versions = new Set()
+ let match
+ while ((match = versionRegex.exec(content)) !== null) {
+ versions.add(match[1])
}
- throw new Error(
- `[buildVersionMap] Could not resolve component version from ${exportFilePath} (${pkgShortName}). ` +
- `Ensure the file has an import like: from '../ComponentName/v1'`
- )
+ if (versions.size === 0) {
+ throw new Error(
+ `[buildVersionMap] Could not resolve component version from ${exportFilePath} (${pkgShortName}). ` +
+ `Ensure the file has an import like: from '../ComponentName/v1'`
+ )
+ }
+
+ return Array.from(versions)
}
/**
From d9b978944ea834a410c737e44107068bd1a8ed17 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bence=20Toppant=C3=B3?=
Date: Tue, 17 Mar 2026 14:43:31 +0100
Subject: [PATCH 2/2] feat(ui-date-input): migrate DateInput and DateInput2
---
cypress/component/DateInput.cy.tsx | 1280 ++++++++++++++---
cypress/component/DateInput2.cy.tsx | 1160 ---------------
docs/guides/upgrade-guide.md | 12 +
packages/__docs__/src/compileMarkdown.tsx | 18 +-
packages/ui-date-input/README.md | 3 +-
.../ui-date-input/src/DateInput/v1/README.md | 10 +-
.../ui-date-input/src/DateInput/v2/README.md | 521 ++++---
.../DateInput/v2/__tests__/DateInput.test.tsx | 1065 +++++---------
.../ui-date-input/src/DateInput/v2/index.tsx | 705 ++++-----
.../ui-date-input/src/DateInput/v2/props.ts | 261 +---
.../ui-date-input/src/DateInput/v2/styles.ts | 52 -
.../ui-date-input/src/DateInput2/v1/README.md | 10 +-
regression-test/src/app/dateinput/page.tsx | 43 +-
13 files changed, 2060 insertions(+), 3080 deletions(-)
delete mode 100644 cypress/component/DateInput2.cy.tsx
delete mode 100644 packages/ui-date-input/src/DateInput/v2/styles.ts
diff --git a/cypress/component/DateInput.cy.tsx b/cypress/component/DateInput.cy.tsx
index 7cec0a7244..006fae0744 100644
--- a/cypress/component/DateInput.cy.tsx
+++ b/cypress/component/DateInput.cy.tsx
@@ -21,252 +21,1140 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+import { useState } from 'react'
import 'cypress-real-events'
import '../support/component'
-import { DateInput, Calendar } from '@instructure/ui/latest'
-
-const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
-
-const generateDays = (count = Calendar.DAY_COUNT) => {
- const days: any[] = []
- const date = new Date('2019-07-28')
-
- while (days.length < count) {
- days.push(
-
- {date.getDate()}
-
- )
- date.setDate(date.getDate() + 1)
- }
+import { DateInput, ApplyLocale } from '@instructure/ui/latest'
+import { SinonSpy } from 'cypress/types/sinon'
+
+const TIMEZONES_DST = [
+ { timezone: 'UTC', expectedDateIsoString: '2020-04-17T00:00:00.000Z' }, // Coordinated Universal Time UTC
+ {
+ timezone: 'America/New_York',
+ expectedDateIsoString: '2020-04-17T04:00:00.000Z'
+ }, // Eastern Time (US & Canada) UTC -4 (Daylight Saving Time)
+ {
+ timezone: 'America/Los_Angeles',
+ expectedDateIsoString: '2020-04-17T07:00:00.000Z'
+ }, // Pacific Time (US & Canada) UTC -7 (Daylight Saving Time)
+ {
+ timezone: 'Europe/London',
+ expectedDateIsoString: '2020-04-16T23:00:00.000Z'
+ }, // United Kingdom Time UTC +1 (Daylight Saving Time)
+ {
+ timezone: 'Europe/Paris',
+ expectedDateIsoString: '2020-04-16T22:00:00.000Z'
+ }, // Central European Time UTC +2 (Daylight Saving Time)
+ { timezone: 'Asia/Tokyo', expectedDateIsoString: '2020-04-16T15:00:00.000Z' }, // Japan Standard Time UTC +9 (No DST)
+ {
+ timezone: 'Australia/Sydney',
+ expectedDateIsoString: '2020-04-16T14:00:00.000Z'
+ }, // Australia Eastern Time UTC +10 (Daylight Saving Time ended in April)
+ {
+ timezone: 'Asia/Kolkata',
+ expectedDateIsoString: '2020-04-16T18:30:00.000Z'
+ }, // India Standard Time UTC +5:30 (No DST)
+ {
+ timezone: 'Africa/Johannesburg',
+ expectedDateIsoString: '2020-04-16T22:00:00.000Z'
+ }, // South Africa Standard Time UTC +2 (No DST)
+ {
+ timezone: 'Asia/Kathmandu',
+ expectedDateIsoString: '2020-04-16T18:15:00.000Z'
+ } // Nepal Standard Time UTC +5:45 (No DST)
+]
+
+const TIMEZONES_NON_DST = [
+ { timezone: 'UTC', expectedDateIsoString: '2020-02-17T00:00:00.000Z' }, // Coordinated Universal Time UTC
+ {
+ timezone: 'America/New_York',
+ expectedDateIsoString: '2020-02-17T05:00:00.000Z'
+ }, // Eastern Time (US & Canada) UTC -5 (Standard Time)
+ {
+ timezone: 'America/Los_Angeles',
+ expectedDateIsoString: '2020-02-17T08:00:00.000Z'
+ }, // Pacific Time (US & Canada) UTC -8 (Standard Time)
+ {
+ timezone: 'Europe/London',
+ expectedDateIsoString: '2020-02-17T00:00:00.000Z'
+ }, // United Kingdom Time UTC +0 (Standard Time)
+ {
+ timezone: 'Europe/Paris',
+ expectedDateIsoString: '2020-02-16T23:00:00.000Z'
+ }, // Central European Time UTC +1 (Standard Time)
+ { timezone: 'Asia/Tokyo', expectedDateIsoString: '2020-02-16T15:00:00.000Z' }, // Japan Standard Time UTC +9 (No DST)
+ {
+ timezone: 'Australia/Sydney',
+ expectedDateIsoString: '2020-02-16T13:00:00.000Z'
+ }, // Australia Eastern Time UTC +11 (Standard Time)
+ {
+ timezone: 'Asia/Kolkata',
+ expectedDateIsoString: '2020-02-16T18:30:00.000Z'
+ }, // India Standard Time UTC +5:30 (No DST)
+ {
+ timezone: 'Africa/Johannesburg',
+ expectedDateIsoString: '2020-02-16T22:00:00.000Z'
+ }, // South Africa Standard Time UTC +2 (No DST)
+ {
+ timezone: 'Asia/Kathmandu',
+ expectedDateIsoString: '2020-02-16T18:15:00.000Z'
+ } // Nepal Standard Time UTC +5:45 (No DST)
+]
- return days
+const LOCALES = [
+ { locale: 'af', textDirection: 'ltr' }, // Afrikaans
+ { locale: 'am', textDirection: 'ltr' }, // Amharic
+ { locale: 'ar-SA', textDirection: 'rtl' }, // Arabic (Saudi Arabia) - Arabic-Indic numerals
+ { locale: 'ar-DZ', textDirection: 'rtl' }, // Arabic (Algeria)
+ { locale: 'ar-EG', textDirection: 'rtl' }, // Arabic (Egypt)
+ { locale: 'ar-SY', textDirection: 'rtl' }, // Arabic (Syria)
+ { locale: 'ar-AE', textDirection: 'rtl' }, // Arabic (United Arab Emirates)
+ { locale: 'ar-IQ', textDirection: 'rtl' }, // Arabic (Iraq)
+ { locale: 'ar-PS', textDirection: 'rtl' }, // Arabic (Palestine)
+ { locale: 'az', textDirection: 'ltr' }, // Azerbaijani
+ { locale: 'be', textDirection: 'ltr' }, // Belarusian
+ { locale: 'bg', textDirection: 'ltr' }, // Bulgarian
+ { locale: 'bn-BD', textDirection: 'ltr' }, // Bengali (Bangladesh) - Bengali numerals
+ { locale: 'bs', textDirection: 'ltr' }, // Bosnian
+ { locale: 'ca', textDirection: 'ltr' }, // Catalan
+ { locale: 'cs', textDirection: 'ltr' }, // Czech
+ { locale: 'cy', textDirection: 'ltr' }, // Welsh
+ { locale: 'da', textDirection: 'ltr' }, // Danish
+ { locale: 'de-DE', textDirection: 'ltr' }, // German (Germany)
+ { locale: 'de-AT', textDirection: 'ltr' }, // German (Austria)
+ { locale: 'el', textDirection: 'ltr' }, // Greek
+ { locale: 'en-US', textDirection: 'ltr' }, // English (United States)
+ { locale: 'en-GB', textDirection: 'ltr' }, // English (United Kingdom)
+ { locale: 'es-ES', textDirection: 'ltr' }, // Spanish (Spain)
+ { locale: 'es-MX', textDirection: 'ltr' }, // Spanish (Mexico)
+ { locale: 'et', textDirection: 'ltr' }, // Estonian
+ { locale: 'fa', textDirection: 'ltr' }, // Persian - Persian numerals
+ { locale: 'fi', textDirection: 'ltr' }, // Finnish
+ { locale: 'fr-FR', textDirection: 'ltr' }, // French (France)
+ { locale: 'fr-CA', textDirection: 'ltr' }, // French (Canada)
+ { locale: 'ga', textDirection: 'ltr' }, // Irish
+ { locale: 'gl', textDirection: 'ltr' }, // Galician
+ { locale: 'gu', textDirection: 'ltr' }, // Gujarati
+ { locale: 'he', textDirection: 'ltr' }, // Hebrew
+ { locale: 'hi', textDirection: 'ltr' }, // Hindi - Devanagari numerals
+ { locale: 'hr', textDirection: 'ltr' }, // Croatian
+ { locale: 'hu', textDirection: 'ltr' }, // Hungarian
+ { locale: 'hy', textDirection: 'ltr' }, // Armenian
+ { locale: 'id', textDirection: 'ltr' }, // Indonesian
+ { locale: 'is', textDirection: 'ltr' }, // Icelandic
+ { locale: 'it-IT', textDirection: 'ltr' }, // Italian (Italy)
+ { locale: 'ja', textDirection: 'ltr' }, // Japanese
+ { locale: 'ka', textDirection: 'ltr' }, // Georgian
+ { locale: 'kk', textDirection: 'ltr' }, // Kazakh
+ { locale: 'km', textDirection: 'ltr' }, // Khmer - Khmer numerals
+ { locale: 'kn', textDirection: 'ltr' }, // Kannada
+ { locale: 'ko', textDirection: 'ltr' }, // Korean
+ { locale: 'lt', textDirection: 'ltr' }, // Lithuanian
+ { locale: 'lv', textDirection: 'ltr' }, // Latvian
+ { locale: 'mk', textDirection: 'ltr' }, // Macedonian
+ { locale: 'ml', textDirection: 'ltr' }, // Malayalam
+ { locale: 'mn', textDirection: 'ltr' }, // Mongolian
+ { locale: 'mr', textDirection: 'ltr' }, // Marathi
+ { locale: 'ms', textDirection: 'ltr' }, // Malay
+ { locale: 'mt', textDirection: 'ltr' }, // Maltese
+ { locale: 'nb', textDirection: 'ltr' }, // Norwegian Bokmål
+ { locale: 'ne', textDirection: 'ltr' }, // Nepali
+ { locale: 'nl', textDirection: 'ltr' }, // Dutch
+ { locale: 'nn', textDirection: 'ltr' }, // Norwegian Nynorsk
+ { locale: 'pa', textDirection: 'ltr' }, // Punjabi
+ { locale: 'pl', textDirection: 'ltr' }, // Polish
+ { locale: 'pt-PT', textDirection: 'ltr' }, // Portuguese (Portugal)
+ { locale: 'pt-BR', textDirection: 'ltr' }, // Portuguese (Brazil)
+ { locale: 'ro', textDirection: 'ltr' }, // Romanian
+ { locale: 'ru', textDirection: 'ltr' }, // Russian
+ { locale: 'si', textDirection: 'ltr' }, // Sinhala
+ { locale: 'sk', textDirection: 'ltr' }, // Slovak
+ { locale: 'sl', textDirection: 'ltr' }, // Slovenian
+ { locale: 'sq', textDirection: 'ltr' }, // Albanian
+ { locale: 'sr', textDirection: 'ltr' }, // Serbian
+ { locale: 'sv-SE', textDirection: 'ltr' }, // Swedish (Sweden)
+ { locale: 'sw', textDirection: 'ltr' }, // Swahili
+ { locale: 'ta', textDirection: 'ltr' }, // Tamil
+ { locale: 'te', textDirection: 'ltr' }, // Telugu
+ { locale: 'th', textDirection: 'ltr' }, // Thai - Thai numerals
+ { locale: 'tr', textDirection: 'ltr' }, // Turkish
+ { locale: 'uk', textDirection: 'ltr' }, // Ukrainian
+ { locale: 'ur', textDirection: 'ltr' }, // Urdu - Arabic script
+ { locale: 'uz', textDirection: 'ltr' }, // Uzbek
+ { locale: 'vi', textDirection: 'ltr' }, // Vietnamese
+ { locale: 'zh-CN', textDirection: 'ltr' }, // Chinese (Simplified)
+ { locale: 'zh-TW', textDirection: 'ltr' }, // Chinese (Traditional)
+ { locale: 'zu', textDirection: 'ltr' } // Zulu
+]
+
+type DateInputExampleProps = {
+ initialValue?: string
+ timezone?: string
+ locale?: string
+ onChange?: SinonSpy
+ onRequestValidateDate?: SinonSpy
}
-describe(' ', () => {
- it('should render label', () => {
- const onChange = cy.spy()
- cy.mount(
+const DateInputExample = ({
+ initialValue = '',
+ timezone = 'UTC',
+ locale = 'en-GB',
+ onChange = cy.spy(),
+ onRequestValidateDate
+}: DateInputExampleProps) => {
+ const [inputValue, setInputValue] = useState(initialValue)
+
+ return (
+ {
+ setInputValue(newInputValue)
+ onChange(_e, newInputValue, newDateString)
+ }}
+ {...(onRequestValidateDate && { onRequestValidateDate })}
+ />
+ )
+}
+
+const RtlExample = (props) => {
+ const [inputValue, setInputValue] = useState(props.initialValue)
+ return (
+
+ screenReaderLabels={{
+ calendarIcon: 'Calendar',
+ nextMonthButton: 'Next month',
+ prevMonthButton: 'Previous month'
+ }}
+ value={inputValue}
+ timezone="UTC"
+ locale={props.locale}
+ onChange={(_e, newInputValue, newDateString) => {
+ setInputValue(newInputValue)
+ props.onChange?.(_e, newInputValue, newDateString)
+ }}
+ />
+
+ )
+}
+
+describe(' ', () => {
+ it('should have screen reader labels for weekday headers', async () => {
+ const expectedWeekdays = [
+ 'Monday',
+ 'Tuesday',
+ 'Wednesday',
+ 'Thursday',
+ 'Friday',
+ 'Saturday',
+ 'Sunday'
+ ]
+ cy.mount( )
+
+ cy.get('button[data-popover-trigger="true"]').click()
+
+ cy.get('th[class*="-calendar__weekdayHeader"]').each(($header, index) => {
+ cy.wrap($header)
+ .find('span[class*="-screenReaderContent"]')
+ .should('have.text', expectedWeekdays[index])
+ })
+ })
+
+ it('should have screen reader labels for calendar days', async () => {
+ cy.mount( )
+
+ // set system date to 2022 march
+ const testDate = new Date(2022, 2, 26)
+ cy.clock(testDate.getTime())
+
+ cy.get('button[data-popover-trigger="true"]').click()
+ cy.tick(1000)
+
+ cy.get('button[class*="-calendarDay"]').each(($day) => {
+ cy.wrap($day)
+ .find('span[class*="-screenReaderContent"]')
+ .should('exist')
+ .and('not.be.empty')
+ })
+
+ cy.contains('button', '10').within(() => {
+ cy.get('span[class*="-screenReaderContent"]').should(
+ 'have.text',
+ '10 March 2022'
+ )
+ })
+
+ cy.contains('button', '17').within(() => {
+ cy.get('span[class*="-screenReaderContent"]').should(
+ 'have.text',
+ '17 March 2022'
+ )
+ })
+ })
+
+ it('should open and close calendar properly and set value when select date from calendar', async () => {
+ cy.mount( )
+
+ cy.get('input').should('have.value', '')
+ cy.get('table').should('not.exist')
+
+ cy.get('button[data-popover-trigger="true"]').click()
+ cy.get('table').should('exist')
+
+ cy.contains('button', '17').click()
+
+ cy.get('input').should('have.value', '17/10/2024')
+ cy.get('table').should('not.exist')
+ })
+
+ it('should select and highlight the correct day on Calendar when value is set', async () => {
+ cy.mount(
+
)
- cy.contains('Choose a date')
+ cy.get('input').should('have.value', '17/03/2022')
+
+ cy.get('button[data-popover-trigger="true"]').click().wait(100)
+
+ cy.get('div[class*="navigation-calendar"]')
+ .should('contain.text', 'March')
+ .and('contain.text', '2022')
+
+ // Get day 16 background color for comparison
+ cy.contains('button', '16').within(() => {
+ cy.get('span[class$="-calendarDay__day"]')
+ .invoke('css', 'background-color')
+ .as('controlDayBgColor')
+ })
+
+ // Compare it to the highlighted day 17
+ cy.contains('button', '17').within(() => {
+ cy.get('span[class$="-calendarDay__day"]')
+ .invoke('css', 'background-color')
+ .then((highlightedDayBgColor) => {
+ cy.get('@controlDayBgColor').should(
+ 'not.equal',
+ highlightedDayBgColor
+ )
+ })
+ })
})
- it('should select the correct day on Calendar when value is set', () => {
+ it('should call onChange with the new typed value', async () => {
+ const newValue = '26/03/2021'
+ const expectedDateIsoString = new Date(Date.UTC(2021, 2, 26)).toISOString()
const onChange = cy.spy()
+ cy.mount(
+
+ )
+
+ cy.get('input').clear().realType('26/03/2021')
+ cy.get('input').blur()
+
+ cy.wrap(onChange).should(
+ 'have.been.calledWith',
+ Cypress.sinon.match.any,
+ newValue,
+ expectedDateIsoString
+ )
+ })
+ it('should respect given local and timezone', async () => {
+ const expectedFormattedValue = '17/10/2022'
+ const expectedDateIsoString = '2022-10-16T21:00:00.000Z' // Africa/Nairobi is GMT +3
+ const onChange = cy.spy()
cy.mount(
-
+
+
+
)
- cy.contains('Choose a date').realClick().wait(100)
- cy.contains('button', '01')
- .should('contain.text', '1 November 2022')
+ cy.get('button[data-popover-trigger="true"]').click()
+
+ cy.get('thead th')
+ .eq(2)
.within(() => {
- cy.get('span[class$="-calendarDay__day"]').should(
- 'have.css',
- 'background-color',
- 'rgb(3, 137, 61)'
+ cy.get('.plugin-cache-1sr5vj2-screenReaderContent').should(
+ 'have.text',
+ 'mercredi'
)
+ cy.get('[aria-hidden="true"]').should('have.text', 'me')
})
+
+ cy.contains('button', '17').click()
+
+ cy.wrap(onChange).should(
+ 'have.been.calledWith',
+ Cypress.sinon.match.any,
+ expectedFormattedValue,
+ expectedDateIsoString
+ )
})
- it('should call onRequestHideCalendar and onRequestValidateDate when date is selected', () => {
- const onRequestHideCalendar = cy.spy()
- const onRequestValidateDate = cy.spy()
+ it('should read local and timezone information from environment context', async () => {
+ const expectedFormattedValue = '2022. 10. 17.'
+ const expectedDateIsoString = '2022-10-17T00:00:00.000Z'
+ const onChange = cy.spy()
cy.mount(
-
- {generateDays()}
-
+
+
+
)
- cy.contains('button', '22')
- .click()
- .then(() => {
- cy.wrap(onRequestHideCalendar).should('have.been.calledOnce')
- cy.wrap(onRequestValidateDate).should('have.been.calledOnce')
+ cy.get('button[data-popover-trigger="true"]').click()
+
+ cy.get('thead th')
+ .eq(2)
+ .within(() => {
+ cy.get('.plugin-cache-1sr5vj2-screenReaderContent').should(
+ 'have.text',
+ 'szerda'
+ )
+ cy.get('[aria-hidden="true"]').should('have.text', 'sze')
})
- })
- it('should call onRequestHideCalendar and onRequestValidateDate when date is selected and is outside month', () => {
- const days = generateDays()
- days[5] = (
-
- outside
-
+ cy.contains('button', '17').click()
+
+ cy.wrap(onChange).should(
+ 'have.been.calledWith',
+ Cypress.sinon.match.any,
+ expectedFormattedValue,
+ expectedDateIsoString
)
+ })
- const onRequestHideCalendar = cy.spy()
- const onRequestValidateDate = cy.spy()
+ describe('with various locales', () => {
+ const getDayInOriginalLanguage = (date, locale) => {
+ // Early guards for locales where Intl.DateTimeFormat can't formatting
+ if (locale === 'gu') return '૧૭' // Return hardcoded Gujarati numeral for 17
+ if (locale === 'hi') return '१७' // Return hardcoded Hindi - Devanagari numeral for 17
+ if (locale === 'km') return '១៧' // Return hardcoded Khmer numeral for 17
+ if (locale === 'kn') return '೧೭' // Return hardcoded Kannada numeral for 17
+ if (locale === 'ne') return '१७' // Return hardcoded Nepali numeral for 17
+ if (locale === 'ta') return '௧௭' // Return hardcoded Tamil numeral for 17
+ if (locale === 'ar-AE') return '١٧' // Return hardcoded Arabic-Indic numeral for 17
- cy.mount(
-
- {days}
-
- )
- cy.contains('Choose date')
- cy.contains('button', 'outside')
- .click()
- .then(() => {
- cy.wrap(onRequestHideCalendar).should('have.been.calledOnce')
- cy.wrap(onRequestValidateDate).should('have.been.calledOnce')
+ const dayString = new Intl.DateTimeFormat(locale, {
+ day: 'numeric',
+ calendar: 'gregory'
+ }).format(date)
+
+ // Trim extra non-digit characters,
+ // but preserve the first sequence of numbers even if they are in a non-Western numeral system
+ return dayString.replace(/[^\p{N}]+$/u, '')
+ }
+
+ const formatDate = (date, locale) => {
+ return new Intl.DateTimeFormat(locale, {
+ day: 'numeric',
+ month: 'numeric',
+ year: 'numeric',
+ calendar: 'gregory'
+ }).format(date)
+ }
+
+ const normalizeWesternDigits = (dateText) => {
+ // Define numeral mappings for different numeral systems
+ const numeralMappings = {
+ // Arabic-Indic
+ '\u0660': '0',
+ '\u0661': '1',
+ '\u0662': '2',
+ '\u0663': '3',
+ '\u0664': '4',
+ '\u0665': '5',
+ '\u0666': '6',
+ '\u0667': '7',
+ '\u0668': '8',
+ '\u0669': '9',
+ // Persian
+ '\u06F0': '0',
+ '\u06F1': '1',
+ '\u06F2': '2',
+ '\u06F3': '3',
+ '\u06F4': '4',
+ '\u06F5': '5',
+ '\u06F6': '6',
+ '\u06F7': '7',
+ '\u06F8': '8',
+ '\u06F9': '9',
+ // Bengali
+ '\u09E6': '0',
+ '\u09E7': '1',
+ '\u09E8': '2',
+ '\u09E9': '3',
+ '\u09EA': '4',
+ '\u09EB': '5',
+ '\u09EC': '6',
+ '\u09ED': '7',
+ '\u09EE': '8',
+ '\u09EF': '9',
+ // Devanagari (Hindi)
+ '\u0966': '0',
+ '\u0967': '1',
+ '\u0968': '2',
+ '\u0969': '3',
+ '\u096A': '4',
+ '\u096B': '5',
+ '\u096C': '6',
+ '\u096D': '7',
+ '\u096E': '8',
+ '\u096F': '9',
+ // Thai
+ '\u0E50': '0',
+ '\u0E51': '1',
+ '\u0E52': '2',
+ '\u0E53': '3',
+ '\u0E54': '4',
+ '\u0E55': '5',
+ '\u0E56': '6',
+ '\u0E57': '7',
+ '\u0E58': '8',
+ '\u0E59': '9',
+ // Khmer
+ '\u17E0': '0',
+ '\u17E1': '1',
+ '\u17E2': '2',
+ '\u17E3': '3',
+ '\u17E4': '4',
+ '\u17E5': '5',
+ '\u17E6': '6',
+ '\u17E7': '7',
+ '\u17E8': '8',
+ '\u17E9': '9'
+ }
+
+ // Return the date with western digits
+ return dateText.replace(
+ /[\u0660-\u0669\u06F0-\u06F9\u09E6-\u09EF\u0966-\u096F\u0E50-\u0E59\u17E0-\u17E9]/g,
+ (d) => numeralMappings[d] || d
+ )
+ }
+
+ const removeRtlMarkers = (dateText) => {
+ return dateText.replace(/\u200f/g, '')
+ }
+
+ const hasRtlMarkers = (inputValue: string) => {
+ return inputValue.includes('')
+ }
+
+ const transformDate = ({ date, locale, shouldRemoveRTL = true }) => {
+ const formatted = formatDate(date, locale) // ١٧/٣/٢٠٢٢
+ const normalized = normalizeWesternDigits(formatted) // 172022/3/ RTL:(17[U+200F]/3[U+200F]/2022)
+ const rtlFree = removeRtlMarkers(normalized) // 17/3/2022
+
+ return shouldRemoveRTL ? rtlFree : normalized
+ }
+
+ LOCALES.forEach(({ locale, textDirection }) => {
+ it(`should call onChange with the correct formatted value and ISO date string for locale: ${locale}`, () => {
+ const onChange = cy.spy()
+ // Setting the initial date ensures that the calendar opening on the desired position
+ const dateForSetInitial = new Date(Date.UTC(2022, 2, 26))
+ const dateForExpectSelect = new Date(Date.UTC(2022, 2, 17)) // Thu, 17 Mar 2022 00:00:00 GMT
+ const expectedDateIsoString = dateForExpectSelect.toISOString() // '2022-03-17T00:00:00.000Z'
+ const expectedOnChangeValue = transformDate({
+ date: dateForExpectSelect,
+ locale,
+ shouldRemoveRTL: false
+ })
+ const expectedFormattedValue = transformDate({
+ date: dateForExpectSelect,
+ locale
+ })
+ const initialDate = transformDate({ date: dateForSetInitial, locale })
+ const dayForSelect = getDayInOriginalLanguage(
+ dateForExpectSelect,
+ locale
+ ) // 17 (in local language)
+
+ cy.mount(
+
+ )
+
+ cy.get('button[data-popover-trigger="true"]').click()
+
+ cy.get('table').should('be.visible')
+
+ cy.contains('button', dayForSelect)
+ .should('be.enabled')
+ .click()
+ .wait(500)
+
+ cy.get('input')
+ .invoke('val')
+ .then((inputValue) => {
+ const inputValueRTLFree = removeRtlMarkers(inputValue)
+ const hasCorrectDirection =
+ (textDirection === 'rtl') === hasRtlMarkers(inputValue as string)
+
+ cy.wrap(hasCorrectDirection).should('be.true')
+ cy.wrap(inputValueRTLFree).should('equal', expectedFormattedValue)
+ cy.wrap(onChange).should(
+ 'have.been.calledWith',
+ Cypress.sinon.match.any,
+ expectedOnChangeValue,
+ expectedDateIsoString
+ )
+ })
})
+ })
})
- it('should call onRequestSelectNextDay on down arrow if calendar is showing', () => {
- const onRequestSelectNextDay = cy.spy()
- cy.mount(
-
- {generateDays()}
-
- )
- cy.contains('Choose date')
- .type('{downarrow}')
- .then(() => {
- cy.wrap(onRequestSelectNextDay).should('have.been.called')
- })
+ it('should change separators according to locale', async () => {
+ cy.mount( )
+
+ cy.get('input').as('input')
+ cy.get('@input').clear().realType('2022-03 26')
+ cy.get('@input').blur()
+ cy.get('input').should('have.value', '2022. 03. 26.')
+
+ cy.get('@input').clear().realType('2022,03/26')
+ cy.get('@input').blur()
+ cy.get('input').should('have.value', '2022. 03. 26.')
})
- it('should not call onRequestSelectNextDay on down arrow if calendar is not showing', () => {
- const onRequestSelectNextDay = cy.spy()
- cy.mount(
-
- {generateDays()}
-
- )
- cy.contains('Choose date')
- .type('{downarrow}')
- .then(() => {
- cy.wrap(onRequestSelectNextDay).should('not.have.been.called')
- })
+ it('should change leading zero according to locale', async () => {
+ cy.mount( )
+
+ cy.get('input').as('input')
+ cy.get('@input').clear().realType('06.03.2022')
+ cy.get('@input').blur()
+ cy.get('input').should('have.value', '6/3/2022')
+
+ cy.mount( )
+
+ cy.get('input').as('input')
+ cy.get('@input').clear().realType('06/3/2022')
+ cy.get('@input').blur()
+ cy.get('input').should('have.value', '6.03.2022')
+
+ cy.mount( )
+
+ cy.get('input').as('input')
+ cy.get('@input').clear().realType('2022,3,6')
+ cy.get('@input').blur()
+ cy.get('input').should('have.value', '2022-03-06')
+ })
+
+ it('should dateFormat prop respect the provided local', async () => {
+ const Example = () => {
+ const [value, setValue] = useState('')
+
+ return (
+ setValue(value)}
+ />
+ )
+ }
+
+ cy.mount( )
+
+ // set system date to 2022 march
+ const testDate = new Date(2022, 2, 26)
+ cy.clock(testDate.getTime())
+
+ cy.get('input').should('have.value', '')
+
+ cy.get('button[data-popover-trigger="true"]').click()
+ cy.tick(1000)
+ cy.contains('button', '17').click()
+ cy.tick(1000)
+
+ cy.get('input').should('have.value', '2022. 03. 17.')
+ })
+
+ TIMEZONES_DST.forEach(({ timezone, expectedDateIsoString }) => {
+ it(`should apply correct timezone and daylight saving adjustments in DST period for: ${timezone}`, () => {
+ const onChange = cy.spy()
+ const initialDate = new Date(Date.UTC(2020, 3, 26)).toLocaleDateString(
+ 'en-GB'
+ )
+ const expectedFormattedValue = '17/04/2020'
+
+ cy.mount(
+
+ )
+
+ cy.get('button[data-popover-trigger="true"]').click()
+ cy.contains('button', '17').click()
+
+ cy.get('input').should('have.value', expectedFormattedValue)
+ cy.wrap(onChange).should(
+ 'have.been.calledWith',
+ Cypress.sinon.match.any,
+ expectedFormattedValue,
+ expectedDateIsoString
+ )
+ })
})
- it('should call onRequestSelectPrevDay on up arrow if calendar is showing', () => {
- const onRequestSelectPrevDay = cy.spy()
+ TIMEZONES_NON_DST.forEach(({ timezone, expectedDateIsoString }) => {
+ it(`should apply correct timezone and daylight saving adjustments in non-DST period for: ${timezone}`, () => {
+ const onChange = cy.spy()
+ const initialDate = new Date(Date.UTC(2020, 1, 26)).toLocaleDateString(
+ 'en-GB'
+ )
+ const expectedFormattedValue = '17/02/2020'
+
+ cy.mount(
+
+ )
+
+ cy.get('button[data-popover-trigger="true"]').click()
+ cy.contains('button', '17').click()
+
+ cy.get('input').should('have.value', expectedFormattedValue)
+ cy.wrap(onChange).should(
+ 'have.been.calledWith',
+ Cypress.sinon.match.any,
+ expectedFormattedValue,
+ expectedDateIsoString
+ )
+ })
+ })
+
+ it('should set custom value through formatter callback', async () => {
+ const customValue = 'customValue'
+ const date = new Date(2020, 10, 10)
+
+ const Example = () => {
+ const [value, setValue] = useState('')
+ return (
+ date,
+ formatter: () => customValue
+ }}
+ onChange={(_e, value) => setValue(value)}
+ />
+ )
+ }
+ cy.mount( )
+
+ cy.get('input').should('have.value', '')
+
+ cy.get('button[data-popover-trigger="true"]').click()
+ cy.contains('button', '17').click()
+
+ cy.get('input').should('have.value', customValue)
+ })
+
+ it('should render year picker based on the withYearPicker prop', async () => {
cy.mount(
- {generateDays()}
-
+ renderLabel="Choose a date"
+ screenReaderLabels={{
+ calendarIcon: 'Calendar',
+ nextMonthButton: 'Next month',
+ prevMonthButton: 'Previous month'
+ }}
+ value=""
+ locale="en-GB"
+ timezone="UTC"
+ withYearPicker={{
+ screenReaderLabel: 'Year picker',
+ startYear: 2022,
+ endYear: 2024
+ }}
+ />
)
- cy.contains('Choose date')
- .type('{uparrow}')
- .then(() => {
- cy.wrap(onRequestSelectPrevDay).should('have.been.calledOnce')
- })
+ // set system date to 2023 march
+ const testDate = new Date(2023, 2, 26)
+ cy.clock(testDate.getTime())
+
+ cy.get('button[data-popover-trigger="true"]').click()
+ cy.tick(1000)
+
+ cy.get('input[id^="Select_"]').as('yearPicker')
+
+ cy.get('@yearPicker').should('have.value', '2023')
+
+ cy.get('[id^="Selectable_"][id$="-description"]').should(
+ 'have.text',
+ 'Year picker'
+ )
+
+ cy.get('@yearPicker').click()
+ cy.tick(1000)
+
+ cy.get('ul[id^="Selectable_"]').should('be.visible')
+ cy.get('[class$="-optionItem"]').as('options')
+ cy.get('@options').should('have.length', 3)
+ cy.get('@options').eq(0).should('contain.text', '2024')
+ cy.get('@options').eq(1).should('contain.text', '2023')
+ cy.get('@options').eq(2).should('contain.text', '2022')
})
- it('should not call onRequestSelectPrevDay on up arrow if calendar is not showing', () => {
- const onRequestSelectPrevDay = cy.spy()
+ it('should set correct value using calendar year picker', async () => {
+ const Example = () => {
+ const [value, setValue] = useState('')
- cy.mount(
-
- {generateDays()}
-
+ return (
+ setValue(value)}
+ withYearPicker={{
+ screenReaderLabel: 'Year picker',
+ startYear: 2022,
+ endYear: 2024
+ }}
+ />
+ )
+ }
+
+ cy.mount( )
+
+ // set system date to 2023 march
+ const testDate = new Date(2023, 2, 26)
+ cy.clock(testDate.getTime())
+
+ cy.get('input').should('have.value', '')
+
+ cy.get('button[data-popover-trigger="true"]').click()
+ cy.tick(1000)
+
+ cy.get('input[id^="Select_"]').as('yearPicker')
+ cy.get('@yearPicker').should('have.value', '2023')
+
+ cy.get('@yearPicker').click()
+ cy.tick(1000)
+
+ cy.get('[class$="-optionItem"]').eq(2).click()
+ cy.tick(1000)
+
+ cy.get('@yearPicker').should('have.value', '2022')
+
+ cy.contains('button', '17').click()
+ cy.tick(1000)
+
+ cy.get('input').should('have.value', '17/03/2022')
+ })
+
+ it('should display correct year in year picker after date is typed into input', async () => {
+ const Example = () => {
+ const [value, setValue] = useState('')
+
+ return (
+ setValue(value)}
+ withYearPicker={{
+ screenReaderLabel: 'Year picker',
+ startYear: 2020,
+ endYear: 2024
+ }}
+ />
+ )
+ }
+
+ cy.mount( )
+
+ cy.get('input').should('have.value', '')
+
+ cy.get('input').clear().realType('26/03/2021')
+ cy.get('input').blur()
+
+ cy.get('input').should('have.value', '26/03/2021')
+
+ cy.get('button[data-popover-trigger="true"]').click()
+
+ cy.get('input[id^="Select_"]').as('yearPicker')
+ cy.get('@yearPicker').should('have.value', '2021')
+ })
+
+ it('should display -- sign in yearPicker if no date value or date is out of range', async () => {
+ const Example = () => {
+ const [value, setValue] = useState('')
+
+ return (
+ setValue(value)}
+ withYearPicker={{
+ screenReaderLabel: 'Year picker',
+ startYear: 2020,
+ endYear: 2022
+ }}
+ />
+ )
+ }
+
+ cy.mount( )
+
+ cy.get('button[data-popover-trigger="true"]').as('calendarBtn')
+ cy.get('input[id^="TextInput_"]').as('input')
+
+ cy.get('@input').should('have.value', '')
+
+ cy.get('@calendarBtn').click()
+
+ cy.get('input[id^="Select_"]').as('yearPicker')
+ cy.get('@yearPicker').should('have.value', '')
+ cy.get('@yearPicker').should('have.attr', 'placeholder', '--')
+
+ cy.get('@input').click().wait(100)
+ cy.get('@input').clear().realType('26/03/1500')
+ cy.get('@input').blur()
+
+ cy.get('@input').should('have.value', '26/03/1500')
+
+ cy.get('@calendarBtn').click()
+
+ cy.get('@yearPicker').should('have.value', '')
+ cy.get('@yearPicker').should('have.attr', 'placeholder', '--')
+ })
+
+ it('should trigger onRequestValidateDate callback on date selection or blur event', async () => {
+ const dateValidationSpy = cy.spy()
+
+ cy.mount( )
+
+ cy.get('button[data-popover-trigger="true"]').as('calendarBtn')
+ cy.get('input[id^="TextInput_"]').as('input')
+
+ cy.get('@calendarBtn').click()
+ cy.contains('button', '17').click()
+
+ cy.wrap(dateValidationSpy).should('have.been.calledOnce')
+
+ cy.get('@input').clear().realType('26/03/2020')
+ cy.get('@input').blur()
+
+ cy.wrap(dateValidationSpy).should('have.been.calledTwice')
+ })
+
+ it('should pass necessary props to parser and formatter via dateFormat prop', async () => {
+ const userDate = '26/03/2021'
+ const parserReturnedDate = new Date(1111, 11, 11)
+
+ const parserSpy = cy.spy(() => parserReturnedDate)
+ const formatterSpy = cy.spy(() => '11/11/1111')
+
+ const Example = () => {
+ const [value, setValue] = useState('')
+
+ return (
+ setValue(value)}
+ />
+ )
+ }
+
+ cy.mount( )
+
+ cy.get('input').as('input')
+ cy.get('@input').clear().realType(userDate)
+ cy.get('@input').blur()
+
+ cy.wrap(parserSpy).should('have.been.calledWith', userDate)
+ cy.wrap(formatterSpy).should('have.been.calledWith', parserReturnedDate)
+ })
+
+ it('should onRequestValidateDate prop pass necessary props to the callback when input value is not a valid date', async () => {
+ const dateValidationSpy = cy.spy()
+ const newValue = 'not a date'
+ const expectedDateIsoString = ''
+
+ cy.mount( )
+
+ cy.get('input').clear().realType(newValue)
+ cy.get('input').blur()
+
+ cy.wrap(dateValidationSpy).should(
+ 'have.been.calledWith',
+ Cypress.sinon.match.any,
+ newValue,
+ expectedDateIsoString
)
- cy.contains('Choose date')
- .type('{uparrow}')
- .then(() => {
- cy.wrap(onRequestSelectPrevDay).should('not.have.been.called')
- })
})
- it('should call onRequestRenderNextMonth and onRequestRenderPrevMonth when calendar arrow buttons are clicked', () => {
- const onRequestRenderNextMonth = cy.spy()
- const onRequestRenderPrevMonth = cy.spy()
+ it('should onRequestValidateDate prop pass necessary props to the callback when input value is a valid date', async () => {
+ const dateValidationSpy = cy.spy()
+ const newValue = '26/03/2021'
+ const expectedDateIsoString = new Date(Date.UTC(2021, 2, 26)).toISOString()
- cy.mount(
- next}
- renderPrevMonthButton={prev }
- isShowingCalendar
- >
- {generateDays()}
-
+ cy.mount( )
+
+ cy.get('input').clear().realType(newValue)
+ cy.get('input').blur()
+
+ cy.wrap(dateValidationSpy).should(
+ 'have.been.calledWith',
+ Cypress.sinon.match.any,
+ newValue,
+ expectedDateIsoString
)
- cy.contains('Choose date')
- cy.contains('button', 'next')
- .click()
- .then(() => {
- cy.wrap(onRequestRenderNextMonth).should('have.been.calledOnce')
- })
+ })
- cy.contains('button', 'prev')
- .click()
- .then(() => {
- cy.wrap(onRequestRenderPrevMonth).should('have.been.calledOnce')
- })
+ const expectedPlaceholders = [
+ { locale: 'hu', expectedPlaceHolder: 'YYYY. MM. DD.' },
+ { locale: 'fr', expectedPlaceHolder: 'DD/MM/YYYY' },
+ { locale: 'en-US', expectedPlaceHolder: 'M/D/YYYY' },
+ { locale: 'ar-SA', expectedPlaceHolder: 'D/M/YYYY' }
+ ]
+
+ expectedPlaceholders.forEach(({ locale, expectedPlaceHolder }) => {
+ it(`should set proper placeholder with locale: ${locale}`, () => {
+ cy.mount( )
+
+ cy.get('input[id^="TextInput_"]').should(
+ 'have.attr',
+ 'placeholder',
+ expectedPlaceHolder
+ )
+ })
+ })
+
+ it(`should set proper placeholder with dateFormat prop formatter callback`, () => {
+ const expectedPlaceHolder = 'YYYY*M*D'
+
+ const Example = () => {
+ const [value, setValue] = useState('')
+
+ return (
+ {
+ return new Date(Date.UTC(1111, 11, 11))
+ },
+ formatter: (date) => {
+ const year = date.getFullYear()
+ const month = date.getMonth() + 1
+ const day = date.getDate()
+
+ // set placeholder according to created date structure 'YYYY*M*D'
+ return `${year}*${month}*${day}`
+ }
+ }}
+ onChange={(_e, value) => setValue(value)}
+ />
+ )
+ }
+ cy.mount( )
+
+ cy.get('input[id^="TextInput_"]').should(
+ 'have.attr',
+ 'placeholder',
+ expectedPlaceHolder
+ )
})
})
diff --git a/cypress/component/DateInput2.cy.tsx b/cypress/component/DateInput2.cy.tsx
deleted file mode 100644
index 622c3b23b6..0000000000
--- a/cypress/component/DateInput2.cy.tsx
+++ /dev/null
@@ -1,1160 +0,0 @@
-/*
- * 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 { useState } from 'react'
-import 'cypress-real-events'
-
-import '../support/component'
-import { DateInput2, ApplyLocale } from '@instructure/ui/latest'
-import { SinonSpy } from 'cypress/types/sinon'
-
-const TIMEZONES_DST = [
- { timezone: 'UTC', expectedDateIsoString: '2020-04-17T00:00:00.000Z' }, // Coordinated Universal Time UTC
- {
- timezone: 'America/New_York',
- expectedDateIsoString: '2020-04-17T04:00:00.000Z'
- }, // Eastern Time (US & Canada) UTC -4 (Daylight Saving Time)
- {
- timezone: 'America/Los_Angeles',
- expectedDateIsoString: '2020-04-17T07:00:00.000Z'
- }, // Pacific Time (US & Canada) UTC -7 (Daylight Saving Time)
- {
- timezone: 'Europe/London',
- expectedDateIsoString: '2020-04-16T23:00:00.000Z'
- }, // United Kingdom Time UTC +1 (Daylight Saving Time)
- {
- timezone: 'Europe/Paris',
- expectedDateIsoString: '2020-04-16T22:00:00.000Z'
- }, // Central European Time UTC +2 (Daylight Saving Time)
- { timezone: 'Asia/Tokyo', expectedDateIsoString: '2020-04-16T15:00:00.000Z' }, // Japan Standard Time UTC +9 (No DST)
- {
- timezone: 'Australia/Sydney',
- expectedDateIsoString: '2020-04-16T14:00:00.000Z'
- }, // Australia Eastern Time UTC +10 (Daylight Saving Time ended in April)
- {
- timezone: 'Asia/Kolkata',
- expectedDateIsoString: '2020-04-16T18:30:00.000Z'
- }, // India Standard Time UTC +5:30 (No DST)
- {
- timezone: 'Africa/Johannesburg',
- expectedDateIsoString: '2020-04-16T22:00:00.000Z'
- }, // South Africa Standard Time UTC +2 (No DST)
- {
- timezone: 'Asia/Kathmandu',
- expectedDateIsoString: '2020-04-16T18:15:00.000Z'
- } // Nepal Standard Time UTC +5:45 (No DST)
-]
-
-const TIMEZONES_NON_DST = [
- { timezone: 'UTC', expectedDateIsoString: '2020-02-17T00:00:00.000Z' }, // Coordinated Universal Time UTC
- {
- timezone: 'America/New_York',
- expectedDateIsoString: '2020-02-17T05:00:00.000Z'
- }, // Eastern Time (US & Canada) UTC -5 (Standard Time)
- {
- timezone: 'America/Los_Angeles',
- expectedDateIsoString: '2020-02-17T08:00:00.000Z'
- }, // Pacific Time (US & Canada) UTC -8 (Standard Time)
- {
- timezone: 'Europe/London',
- expectedDateIsoString: '2020-02-17T00:00:00.000Z'
- }, // United Kingdom Time UTC +0 (Standard Time)
- {
- timezone: 'Europe/Paris',
- expectedDateIsoString: '2020-02-16T23:00:00.000Z'
- }, // Central European Time UTC +1 (Standard Time)
- { timezone: 'Asia/Tokyo', expectedDateIsoString: '2020-02-16T15:00:00.000Z' }, // Japan Standard Time UTC +9 (No DST)
- {
- timezone: 'Australia/Sydney',
- expectedDateIsoString: '2020-02-16T13:00:00.000Z'
- }, // Australia Eastern Time UTC +11 (Standard Time)
- {
- timezone: 'Asia/Kolkata',
- expectedDateIsoString: '2020-02-16T18:30:00.000Z'
- }, // India Standard Time UTC +5:30 (No DST)
- {
- timezone: 'Africa/Johannesburg',
- expectedDateIsoString: '2020-02-16T22:00:00.000Z'
- }, // South Africa Standard Time UTC +2 (No DST)
- {
- timezone: 'Asia/Kathmandu',
- expectedDateIsoString: '2020-02-16T18:15:00.000Z'
- } // Nepal Standard Time UTC +5:45 (No DST)
-]
-
-const LOCALES = [
- { locale: 'af', textDirection: 'ltr' }, // Afrikaans
- { locale: 'am', textDirection: 'ltr' }, // Amharic
- { locale: 'ar-SA', textDirection: 'rtl' }, // Arabic (Saudi Arabia) - Arabic-Indic numerals
- { locale: 'ar-DZ', textDirection: 'rtl' }, // Arabic (Algeria)
- { locale: 'ar-EG', textDirection: 'rtl' }, // Arabic (Egypt)
- { locale: 'ar-SY', textDirection: 'rtl' }, // Arabic (Syria)
- { locale: 'ar-AE', textDirection: 'rtl' }, // Arabic (United Arab Emirates)
- { locale: 'ar-IQ', textDirection: 'rtl' }, // Arabic (Iraq)
- { locale: 'ar-PS', textDirection: 'rtl' }, // Arabic (Palestine)
- { locale: 'az', textDirection: 'ltr' }, // Azerbaijani
- { locale: 'be', textDirection: 'ltr' }, // Belarusian
- { locale: 'bg', textDirection: 'ltr' }, // Bulgarian
- { locale: 'bn-BD', textDirection: 'ltr' }, // Bengali (Bangladesh) - Bengali numerals
- { locale: 'bs', textDirection: 'ltr' }, // Bosnian
- { locale: 'ca', textDirection: 'ltr' }, // Catalan
- { locale: 'cs', textDirection: 'ltr' }, // Czech
- { locale: 'cy', textDirection: 'ltr' }, // Welsh
- { locale: 'da', textDirection: 'ltr' }, // Danish
- { locale: 'de-DE', textDirection: 'ltr' }, // German (Germany)
- { locale: 'de-AT', textDirection: 'ltr' }, // German (Austria)
- { locale: 'el', textDirection: 'ltr' }, // Greek
- { locale: 'en-US', textDirection: 'ltr' }, // English (United States)
- { locale: 'en-GB', textDirection: 'ltr' }, // English (United Kingdom)
- { locale: 'es-ES', textDirection: 'ltr' }, // Spanish (Spain)
- { locale: 'es-MX', textDirection: 'ltr' }, // Spanish (Mexico)
- { locale: 'et', textDirection: 'ltr' }, // Estonian
- { locale: 'fa', textDirection: 'ltr' }, // Persian - Persian numerals
- { locale: 'fi', textDirection: 'ltr' }, // Finnish
- { locale: 'fr-FR', textDirection: 'ltr' }, // French (France)
- { locale: 'fr-CA', textDirection: 'ltr' }, // French (Canada)
- { locale: 'ga', textDirection: 'ltr' }, // Irish
- { locale: 'gl', textDirection: 'ltr' }, // Galician
- { locale: 'gu', textDirection: 'ltr' }, // Gujarati
- { locale: 'he', textDirection: 'ltr' }, // Hebrew
- { locale: 'hi', textDirection: 'ltr' }, // Hindi - Devanagari numerals
- { locale: 'hr', textDirection: 'ltr' }, // Croatian
- { locale: 'hu', textDirection: 'ltr' }, // Hungarian
- { locale: 'hy', textDirection: 'ltr' }, // Armenian
- { locale: 'id', textDirection: 'ltr' }, // Indonesian
- { locale: 'is', textDirection: 'ltr' }, // Icelandic
- { locale: 'it-IT', textDirection: 'ltr' }, // Italian (Italy)
- { locale: 'ja', textDirection: 'ltr' }, // Japanese
- { locale: 'ka', textDirection: 'ltr' }, // Georgian
- { locale: 'kk', textDirection: 'ltr' }, // Kazakh
- { locale: 'km', textDirection: 'ltr' }, // Khmer - Khmer numerals
- { locale: 'kn', textDirection: 'ltr' }, // Kannada
- { locale: 'ko', textDirection: 'ltr' }, // Korean
- { locale: 'lt', textDirection: 'ltr' }, // Lithuanian
- { locale: 'lv', textDirection: 'ltr' }, // Latvian
- { locale: 'mk', textDirection: 'ltr' }, // Macedonian
- { locale: 'ml', textDirection: 'ltr' }, // Malayalam
- { locale: 'mn', textDirection: 'ltr' }, // Mongolian
- { locale: 'mr', textDirection: 'ltr' }, // Marathi
- { locale: 'ms', textDirection: 'ltr' }, // Malay
- { locale: 'mt', textDirection: 'ltr' }, // Maltese
- { locale: 'nb', textDirection: 'ltr' }, // Norwegian Bokmål
- { locale: 'ne', textDirection: 'ltr' }, // Nepali
- { locale: 'nl', textDirection: 'ltr' }, // Dutch
- { locale: 'nn', textDirection: 'ltr' }, // Norwegian Nynorsk
- { locale: 'pa', textDirection: 'ltr' }, // Punjabi
- { locale: 'pl', textDirection: 'ltr' }, // Polish
- { locale: 'pt-PT', textDirection: 'ltr' }, // Portuguese (Portugal)
- { locale: 'pt-BR', textDirection: 'ltr' }, // Portuguese (Brazil)
- { locale: 'ro', textDirection: 'ltr' }, // Romanian
- { locale: 'ru', textDirection: 'ltr' }, // Russian
- { locale: 'si', textDirection: 'ltr' }, // Sinhala
- { locale: 'sk', textDirection: 'ltr' }, // Slovak
- { locale: 'sl', textDirection: 'ltr' }, // Slovenian
- { locale: 'sq', textDirection: 'ltr' }, // Albanian
- { locale: 'sr', textDirection: 'ltr' }, // Serbian
- { locale: 'sv-SE', textDirection: 'ltr' }, // Swedish (Sweden)
- { locale: 'sw', textDirection: 'ltr' }, // Swahili
- { locale: 'ta', textDirection: 'ltr' }, // Tamil
- { locale: 'te', textDirection: 'ltr' }, // Telugu
- { locale: 'th', textDirection: 'ltr' }, // Thai - Thai numerals
- { locale: 'tr', textDirection: 'ltr' }, // Turkish
- { locale: 'uk', textDirection: 'ltr' }, // Ukrainian
- { locale: 'ur', textDirection: 'ltr' }, // Urdu - Arabic script
- { locale: 'uz', textDirection: 'ltr' }, // Uzbek
- { locale: 'vi', textDirection: 'ltr' }, // Vietnamese
- { locale: 'zh-CN', textDirection: 'ltr' }, // Chinese (Simplified)
- { locale: 'zh-TW', textDirection: 'ltr' }, // Chinese (Traditional)
- { locale: 'zu', textDirection: 'ltr' } // Zulu
-]
-
-type DateInputExampleProps = {
- initialValue?: string
- timezone?: string
- locale?: string
- onChange?: SinonSpy
- onRequestValidateDate?: SinonSpy
-}
-
-const DateInputExample = ({
- initialValue = '',
- timezone = 'UTC',
- locale = 'en-GB',
- onChange = cy.spy(),
- onRequestValidateDate
-}: DateInputExampleProps) => {
- const [inputValue, setInputValue] = useState(initialValue)
-
- return (
- {
- setInputValue(newInputValue)
- onChange(_e, newInputValue, newDateString)
- }}
- {...(onRequestValidateDate && { onRequestValidateDate })}
- />
- )
-}
-
-const RtlExample = (props) => {
- const [inputValue, setInputValue] = useState(props.initialValue)
- return (
-
- {
- setInputValue(newInputValue)
- props.onChange?.(_e, newInputValue, newDateString)
- }}
- />
-
- )
-}
-
-describe(' ', () => {
- it('should have screen reader labels for weekday headers', async () => {
- const expectedWeekdays = [
- 'Monday',
- 'Tuesday',
- 'Wednesday',
- 'Thursday',
- 'Friday',
- 'Saturday',
- 'Sunday'
- ]
- cy.mount( )
-
- cy.get('button[data-popover-trigger="true"]').click()
-
- cy.get('th[class*="-calendar__weekdayHeader"]').each(($header, index) => {
- cy.wrap($header)
- .find('span[class*="-screenReaderContent"]')
- .should('have.text', expectedWeekdays[index])
- })
- })
-
- it('should have screen reader labels for calendar days', async () => {
- cy.mount( )
-
- // set system date to 2022 march
- const testDate = new Date(2022, 2, 26)
- cy.clock(testDate.getTime())
-
- cy.get('button[data-popover-trigger="true"]').click()
- cy.tick(1000)
-
- cy.get('button[class*="-calendarDay"]').each(($day) => {
- cy.wrap($day)
- .find('span[class*="-screenReaderContent"]')
- .should('exist')
- .and('not.be.empty')
- })
-
- cy.contains('button', '10').within(() => {
- cy.get('span[class*="-screenReaderContent"]').should(
- 'have.text',
- '10 March 2022'
- )
- })
-
- cy.contains('button', '17').within(() => {
- cy.get('span[class*="-screenReaderContent"]').should(
- 'have.text',
- '17 March 2022'
- )
- })
- })
-
- it('should open and close calendar properly and set value when select date from calendar', async () => {
- cy.mount( )
-
- cy.get('input').should('have.value', '')
- cy.get('table').should('not.exist')
-
- cy.get('button[data-popover-trigger="true"]').click()
- cy.get('table').should('exist')
-
- cy.contains('button', '17').click()
-
- cy.get('input').should('have.value', '17/10/2024')
- cy.get('table').should('not.exist')
- })
-
- it('should select and highlight the correct day on Calendar when value is set', async () => {
- cy.mount(
-
- )
-
- cy.get('input').should('have.value', '17/03/2022')
-
- cy.get('button[data-popover-trigger="true"]').click().wait(100)
-
- cy.get('div[class*="navigation-calendar"]')
- .should('contain.text', 'March')
- .and('contain.text', '2022')
-
- // Get day 16 background color for comparison
- cy.contains('button', '16').within(() => {
- cy.get('span[class$="-calendarDay__day"]')
- .invoke('css', 'background-color')
- .as('controlDayBgColor')
- })
-
- // Compare it to the highlighted day 17
- cy.contains('button', '17').within(() => {
- cy.get('span[class$="-calendarDay__day"]')
- .invoke('css', 'background-color')
- .then((highlightedDayBgColor) => {
- cy.get('@controlDayBgColor').should(
- 'not.equal',
- highlightedDayBgColor
- )
- })
- })
- })
-
- it('should call onChange with the new typed value', async () => {
- const newValue = '26/03/2021'
- const expectedDateIsoString = new Date(Date.UTC(2021, 2, 26)).toISOString()
- const onChange = cy.spy()
- cy.mount(
-
- )
-
- cy.get('input').clear().realType('26/03/2021')
- cy.get('input').blur()
-
- cy.wrap(onChange).should(
- 'have.been.calledWith',
- Cypress.sinon.match.any,
- newValue,
- expectedDateIsoString
- )
- })
-
- it('should respect given local and timezone', async () => {
- const expectedFormattedValue = '17/10/2022'
- const expectedDateIsoString = '2022-10-16T21:00:00.000Z' // Africa/Nairobi is GMT +3
- const onChange = cy.spy()
- cy.mount(
-
-
-
- )
-
- cy.get('button[data-popover-trigger="true"]').click()
-
- cy.get('thead th')
- .eq(2)
- .within(() => {
- cy.get('.plugin-cache-1sr5vj2-screenReaderContent').should(
- 'have.text',
- 'mercredi'
- )
- cy.get('[aria-hidden="true"]').should('have.text', 'me')
- })
-
- cy.contains('button', '17').click()
-
- cy.wrap(onChange).should(
- 'have.been.calledWith',
- Cypress.sinon.match.any,
- expectedFormattedValue,
- expectedDateIsoString
- )
- })
-
- it('should read local and timezone information from environment context', async () => {
- const expectedFormattedValue = '2022. 10. 17.'
- const expectedDateIsoString = '2022-10-17T00:00:00.000Z'
- const onChange = cy.spy()
-
- cy.mount(
-
-
-
- )
-
- cy.get('button[data-popover-trigger="true"]').click()
-
- cy.get('thead th')
- .eq(2)
- .within(() => {
- cy.get('.plugin-cache-1sr5vj2-screenReaderContent').should(
- 'have.text',
- 'szerda'
- )
- cy.get('[aria-hidden="true"]').should('have.text', 'sze')
- })
-
- cy.contains('button', '17').click()
-
- cy.wrap(onChange).should(
- 'have.been.calledWith',
- Cypress.sinon.match.any,
- expectedFormattedValue,
- expectedDateIsoString
- )
- })
-
- describe('with various locales', () => {
- const getDayInOriginalLanguage = (date, locale) => {
- // Early guards for locales where Intl.DateTimeFormat can't formatting
- if (locale === 'gu') return '૧૭' // Return hardcoded Gujarati numeral for 17
- if (locale === 'hi') return '१७' // Return hardcoded Hindi - Devanagari numeral for 17
- if (locale === 'km') return '១៧' // Return hardcoded Khmer numeral for 17
- if (locale === 'kn') return '೧೭' // Return hardcoded Kannada numeral for 17
- if (locale === 'ne') return '१७' // Return hardcoded Nepali numeral for 17
- if (locale === 'ta') return '௧௭' // Return hardcoded Tamil numeral for 17
- if (locale === 'ar-AE') return '١٧' // Return hardcoded Arabic-Indic numeral for 17
-
- const dayString = new Intl.DateTimeFormat(locale, {
- day: 'numeric',
- calendar: 'gregory'
- }).format(date)
-
- // Trim extra non-digit characters,
- // but preserve the first sequence of numbers even if they are in a non-Western numeral system
- return dayString.replace(/[^\p{N}]+$/u, '')
- }
-
- const formatDate = (date, locale) => {
- return new Intl.DateTimeFormat(locale, {
- day: 'numeric',
- month: 'numeric',
- year: 'numeric',
- calendar: 'gregory'
- }).format(date)
- }
-
- const normalizeWesternDigits = (dateText) => {
- // Define numeral mappings for different numeral systems
- const numeralMappings = {
- // Arabic-Indic
- '\u0660': '0',
- '\u0661': '1',
- '\u0662': '2',
- '\u0663': '3',
- '\u0664': '4',
- '\u0665': '5',
- '\u0666': '6',
- '\u0667': '7',
- '\u0668': '8',
- '\u0669': '9',
- // Persian
- '\u06F0': '0',
- '\u06F1': '1',
- '\u06F2': '2',
- '\u06F3': '3',
- '\u06F4': '4',
- '\u06F5': '5',
- '\u06F6': '6',
- '\u06F7': '7',
- '\u06F8': '8',
- '\u06F9': '9',
- // Bengali
- '\u09E6': '0',
- '\u09E7': '1',
- '\u09E8': '2',
- '\u09E9': '3',
- '\u09EA': '4',
- '\u09EB': '5',
- '\u09EC': '6',
- '\u09ED': '7',
- '\u09EE': '8',
- '\u09EF': '9',
- // Devanagari (Hindi)
- '\u0966': '0',
- '\u0967': '1',
- '\u0968': '2',
- '\u0969': '3',
- '\u096A': '4',
- '\u096B': '5',
- '\u096C': '6',
- '\u096D': '7',
- '\u096E': '8',
- '\u096F': '9',
- // Thai
- '\u0E50': '0',
- '\u0E51': '1',
- '\u0E52': '2',
- '\u0E53': '3',
- '\u0E54': '4',
- '\u0E55': '5',
- '\u0E56': '6',
- '\u0E57': '7',
- '\u0E58': '8',
- '\u0E59': '9',
- // Khmer
- '\u17E0': '0',
- '\u17E1': '1',
- '\u17E2': '2',
- '\u17E3': '3',
- '\u17E4': '4',
- '\u17E5': '5',
- '\u17E6': '6',
- '\u17E7': '7',
- '\u17E8': '8',
- '\u17E9': '9'
- }
-
- // Return the date with western digits
- return dateText.replace(
- /[\u0660-\u0669\u06F0-\u06F9\u09E6-\u09EF\u0966-\u096F\u0E50-\u0E59\u17E0-\u17E9]/g,
- (d) => numeralMappings[d] || d
- )
- }
-
- const removeRtlMarkers = (dateText) => {
- return dateText.replace(/\u200f/g, '')
- }
-
- const hasRtlMarkers = (inputValue: string) => {
- return inputValue.includes('')
- }
-
- const transformDate = ({ date, locale, shouldRemoveRTL = true }) => {
- const formatted = formatDate(date, locale) // ١٧/٣/٢٠٢٢
- const normalized = normalizeWesternDigits(formatted) // 172022/3/ RTL:(17[U+200F]/3[U+200F]/2022)
- const rtlFree = removeRtlMarkers(normalized) // 17/3/2022
-
- return shouldRemoveRTL ? rtlFree : normalized
- }
-
- LOCALES.forEach(({ locale, textDirection }) => {
- it(`should call onChange with the correct formatted value and ISO date string for locale: ${locale}`, () => {
- const onChange = cy.spy()
- // Setting the initial date ensures that the calendar opening on the desired position
- const dateForSetInitial = new Date(Date.UTC(2022, 2, 26))
- const dateForExpectSelect = new Date(Date.UTC(2022, 2, 17)) // Thu, 17 Mar 2022 00:00:00 GMT
- const expectedDateIsoString = dateForExpectSelect.toISOString() // '2022-03-17T00:00:00.000Z'
- const expectedOnChangeValue = transformDate({
- date: dateForExpectSelect,
- locale,
- shouldRemoveRTL: false
- })
- const expectedFormattedValue = transformDate({
- date: dateForExpectSelect,
- locale
- })
- const initialDate = transformDate({ date: dateForSetInitial, locale })
- const dayForSelect = getDayInOriginalLanguage(
- dateForExpectSelect,
- locale
- ) // 17 (in local language)
-
- cy.mount(
-
- )
-
- cy.get('button[data-popover-trigger="true"]').click()
-
- cy.get('table').should('be.visible')
-
- cy.contains('button', dayForSelect)
- .should('be.enabled')
- .click()
- .wait(500)
-
- cy.get('input')
- .invoke('val')
- .then((inputValue) => {
- const inputValueRTLFree = removeRtlMarkers(inputValue)
- const hasCorrectDirection =
- (textDirection === 'rtl') === hasRtlMarkers(inputValue as string)
-
- cy.wrap(hasCorrectDirection).should('be.true')
- cy.wrap(inputValueRTLFree).should('equal', expectedFormattedValue)
- cy.wrap(onChange).should(
- 'have.been.calledWith',
- Cypress.sinon.match.any,
- expectedOnChangeValue,
- expectedDateIsoString
- )
- })
- })
- })
- })
-
- it('should change separators according to locale', async () => {
- cy.mount( )
-
- cy.get('input').as('input')
- cy.get('@input').clear().realType('2022-03 26')
- cy.get('@input').blur()
- cy.get('input').should('have.value', '2022. 03. 26.')
-
- cy.get('@input').clear().realType('2022,03/26')
- cy.get('@input').blur()
- cy.get('input').should('have.value', '2022. 03. 26.')
- })
-
- it('should change leading zero according to locale', async () => {
- cy.mount( )
-
- cy.get('input').as('input')
- cy.get('@input').clear().realType('06.03.2022')
- cy.get('@input').blur()
- cy.get('input').should('have.value', '6/3/2022')
-
- cy.mount( )
-
- cy.get('input').as('input')
- cy.get('@input').clear().realType('06/3/2022')
- cy.get('@input').blur()
- cy.get('input').should('have.value', '6.03.2022')
-
- cy.mount( )
-
- cy.get('input').as('input')
- cy.get('@input').clear().realType('2022,3,6')
- cy.get('@input').blur()
- cy.get('input').should('have.value', '2022-03-06')
- })
-
- it('should dateFormat prop respect the provided local', async () => {
- const Example = () => {
- const [value, setValue] = useState('')
-
- return (
- setValue(value)}
- />
- )
- }
-
- cy.mount( )
-
- // set system date to 2022 march
- const testDate = new Date(2022, 2, 26)
- cy.clock(testDate.getTime())
-
- cy.get('input').should('have.value', '')
-
- cy.get('button[data-popover-trigger="true"]').click()
- cy.tick(1000)
- cy.contains('button', '17').click()
- cy.tick(1000)
-
- cy.get('input').should('have.value', '2022. 03. 17.')
- })
-
- TIMEZONES_DST.forEach(({ timezone, expectedDateIsoString }) => {
- it(`should apply correct timezone and daylight saving adjustments in DST period for: ${timezone}`, () => {
- const onChange = cy.spy()
- const initialDate = new Date(Date.UTC(2020, 3, 26)).toLocaleDateString(
- 'en-GB'
- )
- const expectedFormattedValue = '17/04/2020'
-
- cy.mount(
-
- )
-
- cy.get('button[data-popover-trigger="true"]').click()
- cy.contains('button', '17').click()
-
- cy.get('input').should('have.value', expectedFormattedValue)
- cy.wrap(onChange).should(
- 'have.been.calledWith',
- Cypress.sinon.match.any,
- expectedFormattedValue,
- expectedDateIsoString
- )
- })
- })
-
- TIMEZONES_NON_DST.forEach(({ timezone, expectedDateIsoString }) => {
- it(`should apply correct timezone and daylight saving adjustments in non-DST period for: ${timezone}`, () => {
- const onChange = cy.spy()
- const initialDate = new Date(Date.UTC(2020, 1, 26)).toLocaleDateString(
- 'en-GB'
- )
- const expectedFormattedValue = '17/02/2020'
-
- cy.mount(
-
- )
-
- cy.get('button[data-popover-trigger="true"]').click()
- cy.contains('button', '17').click()
-
- cy.get('input').should('have.value', expectedFormattedValue)
- cy.wrap(onChange).should(
- 'have.been.calledWith',
- Cypress.sinon.match.any,
- expectedFormattedValue,
- expectedDateIsoString
- )
- })
- })
-
- it('should set custom value through formatter callback', async () => {
- const customValue = 'customValue'
- const date = new Date(2020, 10, 10)
-
- const Example = () => {
- const [value, setValue] = useState('')
-
- return (
- date,
- formatter: () => customValue
- }}
- onChange={(_e, value) => setValue(value)}
- />
- )
- }
- cy.mount( )
-
- cy.get('input').should('have.value', '')
-
- cy.get('button[data-popover-trigger="true"]').click()
- cy.contains('button', '17').click()
-
- cy.get('input').should('have.value', customValue)
- })
-
- it('should render year picker based on the withYearPicker prop', async () => {
- cy.mount(
-
- )
- // set system date to 2023 march
- const testDate = new Date(2023, 2, 26)
- cy.clock(testDate.getTime())
-
- cy.get('button[data-popover-trigger="true"]').click()
- cy.tick(1000)
-
- cy.get('input[id^="Select_"]').as('yearPicker')
-
- cy.get('@yearPicker').should('have.value', '2023')
-
- cy.get('[id^="Selectable_"][id$="-description"]').should(
- 'have.text',
- 'Year picker'
- )
-
- cy.get('@yearPicker').click()
- cy.tick(1000)
-
- cy.get('ul[id^="Selectable_"]').should('be.visible')
- cy.get('[class$="-optionItem"]').as('options')
- cy.get('@options').should('have.length', 3)
- cy.get('@options').eq(0).should('contain.text', '2024')
- cy.get('@options').eq(1).should('contain.text', '2023')
- cy.get('@options').eq(2).should('contain.text', '2022')
- })
-
- it('should set correct value using calendar year picker', async () => {
- const Example = () => {
- const [value, setValue] = useState('')
-
- return (
- setValue(value)}
- withYearPicker={{
- screenReaderLabel: 'Year picker',
- startYear: 2022,
- endYear: 2024
- }}
- />
- )
- }
-
- cy.mount( )
-
- // set system date to 2023 march
- const testDate = new Date(2023, 2, 26)
- cy.clock(testDate.getTime())
-
- cy.get('input').should('have.value', '')
-
- cy.get('button[data-popover-trigger="true"]').click()
- cy.tick(1000)
-
- cy.get('input[id^="Select_"]').as('yearPicker')
- cy.get('@yearPicker').should('have.value', '2023')
-
- cy.get('@yearPicker').click()
- cy.tick(1000)
-
- cy.get('[class$="-optionItem"]').eq(2).click()
- cy.tick(1000)
-
- cy.get('@yearPicker').should('have.value', '2022')
-
- cy.contains('button', '17').click()
- cy.tick(1000)
-
- cy.get('input').should('have.value', '17/03/2022')
- })
-
- it('should display correct year in year picker after date is typed into input', async () => {
- const Example = () => {
- const [value, setValue] = useState('')
-
- return (
- setValue(value)}
- withYearPicker={{
- screenReaderLabel: 'Year picker',
- startYear: 2020,
- endYear: 2024
- }}
- />
- )
- }
-
- cy.mount( )
-
- cy.get('input').should('have.value', '')
-
- cy.get('input').clear().realType('26/03/2021')
- cy.get('input').blur()
-
- cy.get('input').should('have.value', '26/03/2021')
-
- cy.get('button[data-popover-trigger="true"]').click()
-
- cy.get('input[id^="Select_"]').as('yearPicker')
- cy.get('@yearPicker').should('have.value', '2021')
- })
-
- it('should display -- sign in yearPicker if no date value or date is out of range', async () => {
- const Example = () => {
- const [value, setValue] = useState('')
-
- return (
- setValue(value)}
- withYearPicker={{
- screenReaderLabel: 'Year picker',
- startYear: 2020,
- endYear: 2022
- }}
- />
- )
- }
-
- cy.mount( )
-
- cy.get('button[data-popover-trigger="true"]').as('calendarBtn')
- cy.get('input[id^="TextInput_"]').as('input')
-
- cy.get('@input').should('have.value', '')
-
- cy.get('@calendarBtn').click()
-
- cy.get('input[id^="Select_"]').as('yearPicker')
- cy.get('@yearPicker').should('have.value', '')
- cy.get('@yearPicker').should('have.attr', 'placeholder', '--')
-
- cy.get('@input').click().wait(100)
- cy.get('@input').clear().realType('26/03/1500')
- cy.get('@input').blur()
-
- cy.get('@input').should('have.value', '26/03/1500')
-
- cy.get('@calendarBtn').click()
-
- cy.get('@yearPicker').should('have.value', '')
- cy.get('@yearPicker').should('have.attr', 'placeholder', '--')
- })
-
- it('should trigger onRequestValidateDate callback on date selection or blur event', async () => {
- const dateValidationSpy = cy.spy()
-
- cy.mount( )
-
- cy.get('button[data-popover-trigger="true"]').as('calendarBtn')
- cy.get('input[id^="TextInput_"]').as('input')
-
- cy.get('@calendarBtn').click()
- cy.contains('button', '17').click()
-
- cy.wrap(dateValidationSpy).should('have.been.calledOnce')
-
- cy.get('@input').clear().realType('26/03/2020')
- cy.get('@input').blur()
-
- cy.wrap(dateValidationSpy).should('have.been.calledTwice')
- })
-
- it('should pass necessary props to parser and formatter via dateFormat prop', async () => {
- const userDate = '26/03/2021'
- const parserReturnedDate = new Date(1111, 11, 11)
-
- const parserSpy = cy.spy(() => parserReturnedDate)
- const formatterSpy = cy.spy(() => '11/11/1111')
-
- const Example = () => {
- const [value, setValue] = useState('')
-
- return (
- setValue(value)}
- />
- )
- }
-
- cy.mount( )
-
- cy.get('input').as('input')
- cy.get('@input').clear().realType(userDate)
- cy.get('@input').blur()
-
- cy.wrap(parserSpy).should('have.been.calledWith', userDate)
- cy.wrap(formatterSpy).should('have.been.calledWith', parserReturnedDate)
- })
-
- it('should onRequestValidateDate prop pass necessary props to the callback when input value is not a valid date', async () => {
- const dateValidationSpy = cy.spy()
- const newValue = 'not a date'
- const expectedDateIsoString = ''
-
- cy.mount( )
-
- cy.get('input').clear().realType(newValue)
- cy.get('input').blur()
-
- cy.wrap(dateValidationSpy).should(
- 'have.been.calledWith',
- Cypress.sinon.match.any,
- newValue,
- expectedDateIsoString
- )
- })
-
- it('should onRequestValidateDate prop pass necessary props to the callback when input value is a valid date', async () => {
- const dateValidationSpy = cy.spy()
- const newValue = '26/03/2021'
- const expectedDateIsoString = new Date(Date.UTC(2021, 2, 26)).toISOString()
-
- cy.mount( )
-
- cy.get('input').clear().realType(newValue)
- cy.get('input').blur()
-
- cy.wrap(dateValidationSpy).should(
- 'have.been.calledWith',
- Cypress.sinon.match.any,
- newValue,
- expectedDateIsoString
- )
- })
-
- const expectedPlaceholders = [
- { locale: 'hu', expectedPlaceHolder: 'YYYY. MM. DD.' },
- { locale: 'fr', expectedPlaceHolder: 'DD/MM/YYYY' },
- { locale: 'en-US', expectedPlaceHolder: 'M/D/YYYY' },
- { locale: 'ar-SA', expectedPlaceHolder: 'D/M/YYYY' }
- ]
-
- expectedPlaceholders.forEach(({ locale, expectedPlaceHolder }) => {
- it(`should set proper placeholder with locale: ${locale}`, () => {
- cy.mount( )
-
- cy.get('input[id^="TextInput_"]').should(
- 'have.attr',
- 'placeholder',
- expectedPlaceHolder
- )
- })
- })
-
- it(`should set proper placeholder with dateFormat prop formatter callback`, () => {
- const expectedPlaceHolder = 'YYYY*M*D'
-
- const Example = () => {
- const [value, setValue] = useState('')
-
- return (
- {
- return new Date(Date.UTC(1111, 11, 11))
- },
- formatter: (date) => {
- const year = date.getFullYear()
- const month = date.getMonth() + 1
- const day = date.getDate()
-
- // set placeholder according to created date structure 'YYYY*M*D'
- return `${year}*${month}*${day}`
- }
- }}
- onChange={(_e, value) => setValue(value)}
- />
- )
- }
- cy.mount( )
-
- cy.get('input[id^="TextInput_"]').should(
- 'have.attr',
- 'placeholder',
- expectedPlaceHolder
- )
- })
-})
diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md
index 1659c3300a..45279c5ae7 100644
--- a/docs/guides/upgrade-guide.md
+++ b/docs/guides/upgrade-guide.md
@@ -231,6 +231,18 @@ type: embed
```
+### DateInput / DateInput2
+
+**Short version:** Use [`DateInput`](/v11_7/DateInput). Forget `DateInput2` exists.
+
+**The full story:** The original `DateInput` had a complex API — you had to manually wire up calendar navigation, day rendering, and date parsing. We wanted to simplify it but couldn't without breaking changes, so we released `DateInput2` as a drop-in alternative with a much simpler API.
+
+Now that InstUI supports component versioning, we no longer need the separate `DateInput2` name. Instead:
+
+- **[DateInput v2](/v11_7/DateInput)** (from v11.7) — the recommended version. Same component that was previously `DateInput2`, same API. **This is the only version that will receive the new theming.**
+- **[DateInput v1](/v11_6/DateInput)** (up to v11.6) — the original component. **Deprecated.** Does not support the new theming system.
+- **[DateInput2 v1](/v11_6/DateInput2)** — **Deprecated.** Will not get a v2 and does not support the new theming system. If you're using `DateInput2`, switch your import to `DateInput` (from v11.7) — the API is identical, no other code changes needed.
+
### ColorPicker
```js
diff --git a/packages/__docs__/src/compileMarkdown.tsx b/packages/__docs__/src/compileMarkdown.tsx
index f57bc378bc..72fff2f7ca 100644
--- a/packages/__docs__/src/compileMarkdown.tsx
+++ b/packages/__docs__/src/compileMarkdown.tsx
@@ -39,7 +39,7 @@ import { Playground } from './Playground'
import { compileAndRenderExample } from './compileAndRenderExample'
import { Heading } from './Heading'
import { Link } from './Link'
-import { navigateTo } from './navigationUtils'
+import { navigateTo, MINOR_VERSION_REGEX } from './navigationUtils'
type CodeData = {
code: string
@@ -286,7 +286,21 @@ const renderer = (title: string) => ({
return
}
e.preventDefault()
- navigateTo(href)
+
+ // Markdown links can point to a specific version of a component,
+ // e.g. href="/v11_7/DateInput". navigateTo() expects the page
+ // name and version as separate arguments, so we need to split
+ // them apart. Plain links like "DateInput" are passed through
+ // as-is.
+ const path = href.replace(/^\/+/, '') // "/v11_7/DateInput" -> "v11_7/DateInput"
+ const [first, second] = path.split('/')
+ const isVersionedLink = MINOR_VERSION_REGEX.test(first) && second
+
+ if (isVersionedLink) {
+ navigateTo(second, { minorVersion: first })
+ } else {
+ navigateTo(href)
+ }
}}
>
{text}
diff --git a/packages/ui-date-input/README.md b/packages/ui-date-input/README.md
index 4cb9e4d9d3..e5adafbb9d 100644
--- a/packages/ui-date-input/README.md
+++ b/packages/ui-date-input/README.md
@@ -10,7 +10,8 @@ A date input component.
The `ui-date-input` package contains the following:
-- [DateInput](DateInput)
+- [DateInput](DateInput) — the recommended date input component
+- [DateInput2](DateInput2) — deprecated, use `DateInput` instead
### Installation
diff --git a/packages/ui-date-input/src/DateInput/v1/README.md b/packages/ui-date-input/src/DateInput/v1/README.md
index 2bfbf3d5cf..34ea0cbfd5 100644
--- a/packages/ui-date-input/src/DateInput/v1/README.md
+++ b/packages/ui-date-input/src/DateInput/v1/README.md
@@ -2,7 +2,15 @@
describes: DateInput
---
-> _Note:_ we recommend to update to the new [`DateInput2`](/#DateInput2) which is easier to configure for developers, has a better UX, better accessibility features and a year picker. `DateInput` will be deprecated in the future.
+> **Deprecated:** This version of `DateInput` is deprecated. Please use the latest version of [`DateInput`](/v11_7/DateInput) which offers easier configuration, better UX, improved accessibility, and a year picker.
+
+### DateInput versions at a glance
+
+| Version | API | Theming | Accessibility | Status |
+| :----------------------------------------- | :--------------------------- | :--------------- | :-------------- | :-------------- |
+| [v11.6 DateInput](/v11_6/DateInput) (this) | Old (manual calendar wiring) | Old theming only | Has a11y issues | **Deprecated** |
+| [v11.6 DateInput2](/v11_6/DateInput2) | New (simple) | Old theming only | Good | **Deprecated** |
+| [v11.7 DateInput](/v11_7/DateInput) | New (simple) | New theming | Good | **Recommended** |
The `DateInput` component provides a visual interface for inputting date data.
diff --git a/packages/ui-date-input/src/DateInput/v2/README.md b/packages/ui-date-input/src/DateInput/v2/README.md
index 0825d4dc43..4c743689d5 100644
--- a/packages/ui-date-input/src/DateInput/v2/README.md
+++ b/packages/ui-date-input/src/DateInput/v2/README.md
@@ -2,314 +2,289 @@
describes: DateInput
---
-> _Note:_ we recommend to update to the new [`DateInput2`](/#DateInput2) which is easier to configure for developers, has a better UX, better accessibility features and a year picker. `DateInput` will be deprecated in the future.
+> _Info_: If you are migrating from the deprecated [`DateInput2`](/v11_6/DateInput2), this `DateInput` component has the same API — you can switch with no code changes.
-The `DateInput` component provides a visual interface for inputting date data.
+### DateInput versions at a glance
-### Composing a DateInput in your Application
+| Version | API | Theming | Accessibility | Status |
+| :----------------------------------------- | :--------------------------- | :--------------- | :-------------- | :-------------- |
+| [v11.6 DateInput](/v11_6/DateInput) | Old (manual calendar wiring) | Old theming only | Has a11y issues | **Deprecated** |
+| [v11.6 DateInput2](/v11_6/DateInput2) | New (simple) | Old theming only | Good | **Deprecated** |
+| [v11.7 DateInput](/v11_7/DateInput) (this) | New (simple) | New theming | Good | **Recommended** |
-`DateInput` uses `Calendar` internally. See [Calendar](Calendar) for more detailed
-documentation and guided examples. `DateInput` shares many of the same `Calendar`
-props and it is created the same way with some additional attributes and callback
-methods for the input. The following example is configured similar to the `Calendar`
-examples using [Moment.js](https://momentjs.com/docs/#/parsing/).
+### Minimal config
-```javascript
+```js
---
type: example
---
-
-class Example extends React.Component {
- state = {
- value: '',
- isShowingCalendar: false,
- todayDate: parseDate('2019-08-28').toISOString(),
- selectedDate: null,
- renderedDate: parseDate('2019-08-01').toISOString(),
- disabledDates: [
- parseDate('2019-08-14').toISOString(),
- parseDate('2019-08-19').toISOString(),
- parseDate('2019-08-29').toISOString()
- ],
- messages: []
- }
-
- generateMonth = (renderedDate = this.state.renderedDate) => {
- const date = parseDate(renderedDate)
- .startOf('month')
- .startOf('week')
-
- return Array.apply(null, Array(Calendar.DAY_COUNT)).map(() => {
- const currentDate = date.clone()
- date.add({days: 1})
-
- // This workaround is needed because moment's `.add({days: 1})` function has a bug that happens when the date added lands perfectly onto the DST cutoff,
- // in these cases adding 1 day results in 23 hours added instead,
- // so `moment.tz('2024-09-07T00:00:00', 'America/Santiago').add({days: 1})` results
- // in "Sat Sep 07 2024 23:00:00 GMT-0400" instead of "Sun Sep 08 2024 00:00:00 GMT-0400".
- // which would cause duplicate dates in the calendar.
- // More info on the bug: https://github.com/moment/moment/issues/4743
- // Please note that this causes one hour of time difference in the affected timezones/dates and to
- // fully solve this bug we need to change to something like luxon which handles this properly
- if (currentDate.clone().format('HH') === '23') {
- return currentDate.clone().add({hours: 1})
- }
-
- return currentDate.clone()
- })
- }
-
- formatDate = (dateInput) => {
- const date = parseDate(dateInput)
- return `${date.format('MMMM')} ${date.format('D')}, ${date.format('YYYY')}`
- }
-
- handleChange = (event, { value }) => {
- const newDateStr = parseDate(value).toISOString()
-
- this.setState(({ renderedDate }) => ({
- value,
- selectedDate: newDateStr,
- renderedDate: newDateStr || renderedDate,
- messages: []
- }))
+ const Example = () => {
+ const [inputValue, setInputValue] = useState('')
+ const [dateString, setDateString] = useState('')
+ return (
+
+
{
+ setInputValue(inputValue)
+ setDateString(dateString)
+ }}
+ invalidDateErrorMessage="Invalid date"
+ />
+
+ Input Value: {inputValue}
+
+ UTC Date String: {dateString}
+
+
+ )
}
- handleShowCalendar = (event) => {
- this.setState({ isShowingCalendar: true })
- }
+ render( )
+```
- handleHideCalendar = (event) => {
- this.setState(({ selectedDate, disabledDates, value }) => ({
- isShowingCalendar: false,
- value: selectedDate ? this.formatDate(selectedDate) : value
- }))
- }
+### Parsing and formatting dates
- handleValidateDate = (event) => {
- this.setState(({ selectedDate, value }) => {
- // We don't have a selectedDate but we have a value. That means that the value
- // could not be parsed and so the date is invalid
- if (!selectedDate && value) {
- return {
- messages: [{ type: 'error', text: 'This date is invalid' }],
- }
- }
- // Display a message if the user has typed in a value that corresponds to a
- // disabledDate
- if (this.isDisabledDate(parseDate(selectedDate))) {
- return {
- messages: [{ type: 'error', text: 'This date is disabled' }],
- }
- }
- })
- }
+When typing in a date manually (instead of using the included picker), the component tries to parse the date as you type it in. By default parsing is based on the user's locale which determines the order of day, month and year (e.g.: a user with US locale will have MONTH/DAY/YEAR order, and someone with GB locale will have DAY/MONTH/YEAR order).
- handleDayClick = (event, { date }) => {
- this.setState({
- selectedDate: date,
- renderedDate: date,
- messages: []
- })
- }
+Any of the following separators can be used when typing a date: `,`, `-`, `.`, `/` or a whitespace however on blur the date will be formatted according to the locale and separators will be changed and leading zeros also adjusted.
- handleSelectNextDay = (event) => {
- this.modifySelectedDate('day', 1)
- }
+If you want different parsing and formatting then the current locale you can use the `dateFormat` prop which accepts either a string with a name of a different locale (so you can use US date format even if the user is France) or a parser and formatter functions.
- handleSelectPrevDay = (event) => {
- this.modifySelectedDate('day', -1)
- }
+The default parser also has a limitation of not working with years before `1000` and after `9999`. These values are invalid by default but not with custom parsers.
- handleRenderNextMonth = (event) => {
- this.modifyRenderedDate('month', 1)
- }
+```js
+---
+type: example
+---
+const Example = () => {
+ const [value, setValue] = useState('')
+ const [value2, setValue2] = useState('')
+ const [value3, setValue3] = useState('')
+
+ return (
+
+ US locale with default format:
+ setValue(value)}
+ />
+ US locale with german date format:
+ setValue2(value)}
+ />
+ US locale with ISO date format:
+ {
+ // split input on '.', whitespace, '/', ',' or '-' using regex: /[.\s/.-]+/
+ // the '+' allows splitting on consecutive delimiters
+ const [year, month, day] = input.split(/[,.\s/.-]+/)
+ const newDate = new Date(year, month-1, day)
+ return isNaN(newDate) ? '' : newDate
+ },
+ formatter: (date) => {
+ // vanilla js formatter but you could use a date library instead
+ const year = date.getFullYear()
+ // month is zero indexed so add 1
+ const month = `${date.getMonth() + 1}`.padStart(2, '0')
+ const day = `${date.getDate()}`.padStart(2, '0')
+ return `${year}-${month}-${day}`
+ }
+ }}
+ onChange={(e, value) => setValue3(value)}
+ />
+
+ )
+}
- handleRenderPrevMonth = (event) => {
- this.modifyRenderedDate('month', -1)
- }
+render( )
+```
- modifyRenderedDate = (type, step) => {
- this.setState(({ renderedDate }) => {
- return { renderedDate: this.modifyDate(renderedDate, type, step) }
- })
- }
+### Timezones
- modifySelectedDate = (type, step) => {
- this.setState(({ selectedDate, renderedDate }) => {
- // We are either going to increase or decrease our selectedDate by 1 day.
- // If we do not have a selectedDate yet, we'll just select the first day of
- // the currently rendered month instead.
- const newDate = selectedDate
- ? this.modifyDate(selectedDate, type, step)
- : parseDate(renderedDate).startOf('month').toISOString()
-
- return {
- selectedDate: newDate,
- renderedDate: newDate,
- value: this.formatDate(newDate),
- messages: []
- }
- })
- }
+In the examples above you can see that the `onChange` callback also return a UTC date string. This means it is timezone adjusted. If the timezone is not set via the `timezone` prop, it is calculated/assumed from the user's machine. So if a user chooses September 10th 2024 with the timezone 'Europe/Budapest', the `onChange` function will return `2024-09-09T22:00:00.000Z` because Budapest is two hours ahead of UTC (summertime).
- modifyDate = (dateStr, type, step) => {
- const date = parseDate(dateStr)
- date.add(step, type)
- return date.toISOString()
- }
+### With year picker
- isDisabledDate = (date, disabledDates = this.state.disabledDates) => {
- return disabledDates.reduce((result, disabledDate) => {
- return result || date.isSame(disabledDate, 'day')
- }, false)
+```js
+---
+type: example
+---
+ const Example = () => {
+ const [inputValue, setInputValue] = useState('')
+ const [dateString, setDateString] = useState('')
+ return (
+
+
{
+ setInputValue(inputValue)
+ setDateString(dateString)
+ }}
+ invalidDateErrorMessage="Invalid date"
+ withYearPicker={{
+ screenReaderLabel: 'Year picker',
+ startYear: 1900,
+ endYear: 2024
+ }}
+ />
+
+ Input Value: {inputValue}
+
+ UTC Date String: {dateString}
+
+
+ )
}
- renderWeekdayLabels = () => {
- const date = parseDate(this.state.renderedDate).startOf('week')
-
- return Array.apply(null, Array(7)).map(() => {
- const currentDate = date.clone()
- date.add(1, 'day')
+ render( )
+```
- return (
-
- {currentDate.format('dd')}
-
- )
- })
- }
+### Date validation
- renderDays () {
- const {
- renderedDate,
- selectedDate,
- todayDate,
- } = this.state
-
- return this.generateMonth().map((date) => {
- const dateStr = date.toISOString()
-
- return (
-
- {date.format('D')}
-
- )
- })
- }
+By default `DateInput` only does date validation if the `invalidDateErrorMessage` prop is provided. Validation is triggered on the blur event of the input field. Invalid dates are determined current locale.
- render () {
- const {
- value,
- isShowingCalendar,
- renderedDate,
- messages
- } = this.state
-
- const date = parseDate(this.state.renderedDate)
-
- const buttonProps = (type = 'prev') => ({
- size: 'small',
- withBackground: false,
- withBorder: false,
- renderIcon: type === 'prev'
- ?
- : ,
- screenReaderLabel: type === 'prev' ? 'Previous month' : 'Next month'
- })
+If you want to do more complex validation (e.g. only allow a subset of dates) you can use the `onRequestValidateDate` and `messages` props.
- return (
-
- {date.format('MMMM')}
- {date.format('YYYY')}
-
- }
- renderPrevMonthButton={ }
- renderNextMonthButton={ }
- renderWeekdayLabels={this.renderWeekdayLabels()}
- >
- {this.renderDays()}
-
- )
+```js
+---
+type: example
+---
+const Example = () => {
+ const [value, setValue] = useState('')
+ const [dateString, setDateString] = useState('')
+ const [messages, setMessages] = useState([])
+
+ const handleDateValidation = (e, inputValue, utcIsoDate) => {
+ // utcIsoDate will be an empty string if the input cannot be parsed as a date
+
+ const date = new Date(utcIsoDate)
+
+ // don't validate empty input
+ if (!utcIsoDate && inputValue.length > 0) {
+ setMessages([{
+ type: 'error',
+ text: 'This is not a valid date'
+ }])
+ } else if (date < new Date('1990-01-01')) {
+ setMessages([{
+ type: 'error',
+ text: 'Select date after January 1, 1990'
+ }])
+ } else {
+ setMessages([])
+ }
}
-}
-const locale = 'en-us'
-const timezone = 'America/Denver'
-
-const parseDate = (dateStr) => {
- return moment.tz(dateStr, [moment.ISO_8601, 'llll', 'LLLL', 'lll', 'LLL', 'll', 'LL', 'l', 'L'], locale, timezone)
+ return (
+ setValue(value)}
+ withYearPicker={{
+ screenReaderLabel: 'Year picker',
+ startYear: 1900,
+ endYear: 2024
+ }}
+ />
+ )
}
render( )
```
-#### Some dates to keep track of
-
-- `todayDate` - the date that represents today
-- `selectedDate` - the user's selected date
-- `renderedDate` - the date that the user is viewing as they navigate the `Calendar`
-- `disabledDates` - any dates that are disabled
-
-#### Rendering `DateInput.Day` children
-
-`DateInput` accepts children of type `DateInput.Day`. Both `DateInput.Day` and
-`Calendar.Day` are exporting the same `Day` component. The documentation for
-`Day` can be found in [Calendar](Calendar).
-
-#### Handling onChange
-
-When the `DateInput` fires an `onChange` event:
+### Date format hint
-- The value should be updated and any messages should be cleared
-- Verify if the value can be parsed as a date
-- If it can be parsed, update the `selectedDate` and `renderedDate` with that date
-- If it cannot be parsed, the `selectedDate` is set to null and the `renderedDate`
- stays the same
+If the `placeholder` property is undefined it will display a hint for the date format (like `DD/MM/YYYY`). Usually it is recommended to leave it as it is for a better user experience.
-#### Handling onRequestHideCalendar
+### Disabling dates
-When the `DateInput` fires `onRequestHideCalendar`:
+You can use the `disabledDates` prop to disable specific dates. It accepts either an array of ISO8601 date strings or a function. Keep in mind that this will only disable the dates in the calendar and does not prevent the user the enter them into the input field. To validate those values please use the `onRequestValidateDate` prop.
-- The calendar should be hidden
-- The value should be updated with a formatted version of the `selectedDate` if
- it exists. See "Formatting user input" below
-
-#### Formatting user input
-
-Date formats can vary widely (ex. '8-9-19' vs '8/9/19'). When the `Calendar` is
-hidden, the input value should be converted to a consistent, standardized format.
-The formatted result of the raw input '8/9/19'
-could be "August 9, 2019".
-
-#### Handling onRequestValidateDate
+```js
+---
+type: example
+---
+const Example = () => {
+ const [inputValue, setInputValue] = useState('2/5/2025')
+ const [dateString, setDateString] = useState('')
+ return (
+ {
+ setInputValue(inputValue)
+ setDateString(dateString)
+ }}
+ invalidDateErrorMessage="Invalid date"
+ />
+ )
+}
-When the `DateInput` fires `onRequestValidateDate`, the provided user input
-should be validated. If the value cannot be parsed as a valid date, or if the
-`selectedDate` is disabled, the user should be notified via the `messages` prop.
+render( )
+```
diff --git a/packages/ui-date-input/src/DateInput/v2/__tests__/DateInput.test.tsx b/packages/ui-date-input/src/DateInput/v2/__tests__/DateInput.test.tsx
index bfb33937af..f7122258c1 100644
--- a/packages/ui-date-input/src/DateInput/v2/__tests__/DateInput.test.tsx
+++ b/packages/ui-date-input/src/DateInput/v2/__tests__/DateInput.test.tsx
@@ -21,48 +21,45 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { vi } from 'vitest'
+import { vi, MockInstance } from 'vitest'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
-import { Calendar } from '@instructure/ui-calendar/latest'
+import { HeartInstUIIcon } from '@instructure/ui-icons'
+
import { DateInput } from '../index'
+import { TextInput } from '@instructure/ui-text-input/latest'
+
+const LABEL_TEXT = 'Choose a date'
+
+const DateInputExample = () => {
+ const [inputValue, setInputValue] = useState('')
+
+ return (
+ {
+ setInputValue(inputValue)
+ }}
+ />
+ )
+}
describe(' ', () => {
- const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
-
- const generateDays = (count = Calendar.DAY_COUNT) => {
- const days = []
- const date = new Date('2019-07-28')
-
- while (days.length < count) {
- days.push(
-
- {date.getDate()}
-
- )
- date.setDate(date.getDate() + 1)
- }
-
- return days
- }
-
- let consoleWarningMock: ReturnType
- let consoleErrorMock: ReturnType
+ let consoleWarningMock: MockInstance
+ let consoleErrorMock: MockInstance
beforeEach(() => {
// Mocking console to prevent test output pollution
- consoleWarningMock = vi
- .spyOn(console, 'warn')
- .mockImplementation(() => {}) as any
- consoleErrorMock = vi
- .spyOn(console, 'error')
- .mockImplementation(() => {}) as any
+ consoleWarningMock = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
@@ -70,779 +67,377 @@ describe(' ', () => {
consoleErrorMock.mockRestore()
})
- it('should render an input and a calendar', async () => {
- const { container, findByRole } = render(
-
- {generateDays()}
-
- )
+ it('should render an input', async () => {
+ const { container } = render( )
const dateInput = container.querySelector('input')
expect(dateInput).toBeInTheDocument()
expect(dateInput).toHaveAttribute('type', 'text')
-
- await userEvent.click(dateInput!)
- const calendarTable = await findByRole('listbox')
- const calendarWrapper = await document.querySelector(
- '[id^="Selectable_"][id$="-list"]'
- )
-
- await waitFor(() => {
- expect(calendarWrapper).toBeInTheDocument()
- expect(calendarTable).toBeInTheDocument()
- })
})
- describe('input', () => {
- it('should render a label', () => {
- const labelText = 'label text'
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('label text')
-
- expect(dateInput).toBeInTheDocument()
- })
+ it('should render an input label', async () => {
+ const { container } = render( )
- it('should set value', () => {
- const value = 'January 5'
+ const label = container.querySelector('label')
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
+ expect(label).toBeInTheDocument()
+ expect(label).toHaveTextContent(LABEL_TEXT)
+ })
- expect(dateInput).toHaveValue(value)
- })
+ it('should render an input placeholder', async () => {
+ const placeholder = 'Placeholder'
+ render(
+
+ )
+ const dateInput = screen.getByLabelText('Choose a date')
- it('should call onChange with the updated value', async () => {
- const onChange = vi.fn()
- const value = 'May 18, 2022'
+ expect(dateInput).toHaveAttribute('placeholder', placeholder)
+ })
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
+ it('should render a calendar icon with screen reader label', async () => {
+ const iconLabel = 'Calendar icon Label'
+ const { container } = render(
+
+ )
+ const calendarIcon = container.querySelector('svg[name="Calendar"]')
+ const calendarLabel = screen.getByText(iconLabel)
- fireEvent.change(dateInput, { target: { value: value } })
- fireEvent.keyDown(dateInput, { key: 'Enter', code: 'Enter' })
- fireEvent.blur(dateInput)
+ expect(calendarIcon).toBeInTheDocument()
+ expect(calendarLabel).toBeInTheDocument()
+ })
- await waitFor(() => {
- expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange.mock.calls[0][1].value).toEqual(
- expect.stringContaining(value)
- )
- })
- })
+ it('refs should return the underlying component', async () => {
+ const inputRef = vi.fn()
+ const ref: React.Ref = { current: null }
+ const { container } = render(
+
+ )
+ const dateInput = container.querySelector('input')
+ expect(inputRef).toHaveBeenCalledWith(dateInput)
+ expect(ref.current!.props.id).toBe('dateInput')
+ expect(dateInput).toBeInTheDocument()
+ })
- it('should call onBlur', () => {
- const onBlur = vi.fn()
+ it('should render a custom calendar icon with screen reader label', async () => {
+ const iconLabel = 'Calendar icon Label'
+ const { container } = render(
+ }
+ />
+ )
+ const calendarIcon = container.querySelector('svg[name="Heart"]')
+ const calendarLabel = screen.getByText(iconLabel)
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
+ expect(calendarIcon).toBeInTheDocument()
+ expect(calendarLabel).toBeInTheDocument()
+ })
- fireEvent.blur(dateInput)
+ it('should not show calendar table by default', async () => {
+ render( )
+ const calendarTable = screen.queryByRole('table')
- expect(onBlur).toHaveBeenCalledTimes(1)
- })
+ expect(calendarTable).not.toBeInTheDocument()
+ })
- it('should correctly set interaction type', async () => {
- const { rerender } = render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
- expect(dateInput).toHaveAttribute('disabled')
+ it('should show calendar table when calendar button is clicked', async () => {
+ render( )
+ const calendarButton = screen.getByRole('button')
- rerender(
-
- {generateDays()}
-
- )
+ expect(calendarButton).toBeInTheDocument()
- const dateInputAfterUpdate = screen.getByLabelText('Choose date')
+ await userEvent.click(calendarButton)
- await waitFor(() => {
- expect(dateInputAfterUpdate).toHaveAttribute('readonly')
- })
+ await waitFor(() => {
+ const calendarTable = screen.queryByRole('table')
+ expect(calendarTable).toBeInTheDocument()
})
+ })
- it('should correctly set disabled', () => {
- render(
-
- {generateDays()}
-
- )
+ it('should render navigation arrow buttons with screen reader labels', async () => {
+ const nextMonthLabel = 'Next month'
+ const prevMonthLabel = 'Previous month'
- const dateInput = screen.getByLabelText('Choose date')
+ render(
+
+ )
+ const calendarButton = screen.getByRole('button')
- expect(dateInput).toHaveAttribute('disabled')
- })
+ await userEvent.click(calendarButton)
- it('should correctly set readOnly', () => {
- render(
-
- {generateDays()}
-
- )
+ await waitFor(() => {
+ const prevMonthButton = screen.getByRole('button', {
+ name: prevMonthLabel
+ })
+ const nextMonthButton = screen.getByRole('button', {
+ name: nextMonthLabel
+ })
- const dateInput = screen.getByLabelText('Choose date')
+ expect(prevMonthButton).toBeInTheDocument()
+ expect(nextMonthButton).toBeInTheDocument()
- expect(dateInput).toHaveAttribute('readOnly')
- })
+ const prevButtonLabel = screen.getByText(prevMonthLabel)
+ const nextButtonLabel = screen.getByText(nextMonthLabel)
- it('should set placeholder', () => {
- const placeholder = 'Start typing to choose a date'
+ expect(prevButtonLabel).toBeInTheDocument()
+ expect(nextButtonLabel).toBeInTheDocument()
- render(
-
- {generateDays()}
-
+ const prevMonthIcon = prevMonthButton.querySelector(
+ 'svg[name="ChevronLeft"]'
)
- const dateInput = screen.getByLabelText('Choose date')
-
- expect(dateInput).toHaveAttribute('placeholder', placeholder)
- })
-
- it('should be requireable', () => {
- render(
-
- {generateDays()}
-
+ const nextMonthIcon = nextMonthButton.querySelector(
+ 'svg[name="ChevronRight"]'
)
- const dateInput = screen.getByLabelText('Choose date *')
- expect(dateInput).toHaveAttribute('required')
+ expect(prevMonthIcon).toBeInTheDocument()
+ expect(nextMonthIcon).toBeInTheDocument()
})
+ })
- it('should provide inputRef', () => {
- const inputRef = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
+ it('should programmatically set and render the initial value', async () => {
+ const value = '26/03/2024'
+ render(
+
+ )
+ const dateInput = screen.getByLabelText('Choose a date')
- expect(inputRef).toHaveBeenCalledWith(dateInput)
- })
+ expect(dateInput).toHaveValue(value)
+ expect(dateInput).toBeInTheDocument()
+ })
- it('should render messages', () => {
- const text = 'The selected date is invalid'
+ it('should set interaction type to disabled', async () => {
+ const interactionDisabled = 'disabled'
+ const { container } = render(
+
+ )
+ const dateInput = container.querySelector('input')
- const { container } = render(
-
- {generateDays()}
-
- )
+ expect(dateInput).toHaveAttribute(interactionDisabled)
+ })
- expect(container).toHaveTextContent(text)
- })
+ it('should set interaction type to readonly', async () => {
+ const interactionReadOnly = 'readonly'
+ const { container } = render(
+
+ )
+ const dateInput = container.querySelector('input')
+ const calendarButton = screen.getByRole('button')
- it('should allow custom props to pass through', () => {
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
+ expect(dateInput).toHaveAttribute(interactionReadOnly)
+ expect(calendarButton).toBeInTheDocument()
- expect(dateInput).toHaveAttribute('data-custom-attr', 'custom value')
- expect(dateInput).toHaveAttribute('name', 'my name')
- })
- })
+ await userEvent.click(calendarButton)
- describe('Calendar', () => {
- it('should show calendar when `isShowingCalendar` is set', async () => {
- const { rerender, queryByRole } = render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
- const calendarTable = await queryByRole('listbox')
- const calendarWrapper = await document.querySelector(
- '[id^="Selectable_"][id$="-list"]'
- )
+ await waitFor(() => {
+ const calendarTable = screen.queryByRole('table')
- expect(dateInput).toBeInTheDocument()
expect(calendarTable).not.toBeInTheDocument()
- expect(calendarWrapper).not.toBeInTheDocument()
-
- rerender(
-
- {generateDays()}
-
- )
- const dateInputAfterUpdate = screen.getByLabelText('Choose date')
- const calendarTableAfterUpdate = await queryByRole('listbox')
- const calendarWrapperAfterUpdate = await document.querySelector(
- '[id^="Selectable_"][id$="-list"]'
- )
-
- await waitFor(() => {
- expect(dateInputAfterUpdate).toBeInTheDocument()
- expect(calendarTableAfterUpdate).toBeInTheDocument()
- expect(calendarWrapperAfterUpdate).toBeInTheDocument()
- })
})
+ })
- describe('onRequestShowCalendar', () => {
- it('should call onRequestShowCalendar when label is clicked', async () => {
- const onRequestShowCalendar = vi.fn()
-
- const { container } = render(
-
- {generateDays()}
-
- )
- const dateInput = container.querySelector(
- 'span[class$="-formFieldLayout__label"]'
- )
-
- expect(dateInput).toHaveTextContent('Choose date')
-
- await userEvent.click(dateInput!)
-
- await waitFor(() => {
- expect(onRequestShowCalendar).toHaveBeenCalled()
- })
- })
-
- it('should call onRequestShowCalendar when input is clicked', async () => {
- const onRequestShowCalendar = vi.fn()
-
- const { container } = render(
-
- {generateDays()}
-
- )
- const dateInput = container.querySelector('input')
-
- await userEvent.click(dateInput!)
-
- await waitFor(() => {
- expect(onRequestShowCalendar).toHaveBeenCalledTimes(1)
- })
- })
-
- it('should call onRequestShowCalendar when input receives space event', async () => {
- const onRequestShowCalendar = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- await userEvent.type(dateInput, '{space}')
-
- await waitFor(() => {
- expect(onRequestShowCalendar).toHaveBeenCalledTimes(1)
- })
- })
-
- it('should not call onRequestShowCalendar when input receives space event if calendar is already showing', async () => {
- const onRequestShowCalendar = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- await userEvent.type(dateInput, '{space}')
-
- await waitFor(() => {
- expect(onRequestShowCalendar).not.toHaveBeenCalled()
- })
- })
-
- it('should call onRequestShowCalendar when input receives down arrow event', async () => {
- const onRequestShowCalendar = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- await userEvent.type(dateInput, '{arrowdown}')
-
- await waitFor(() => {
- expect(onRequestShowCalendar).toHaveBeenCalledTimes(1)
- })
- })
-
- it('should not call onRequestShowCalendar when input receives down arrow event if calendar is already showing', async () => {
- const onRequestShowCalendar = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- await userEvent.type(dateInput, '{arrowdown}')
-
- await waitFor(() => {
- expect(onRequestShowCalendar).not.toHaveBeenCalled()
- })
- })
-
- it('should call onRequestShowCalendar when input receives up arrow event', async () => {
- const onRequestShowCalendar = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- await userEvent.type(dateInput, '{arrowup}')
-
- await waitFor(() => {
- expect(onRequestShowCalendar).toHaveBeenCalledTimes(1)
- })
- })
-
- it('should not call onRequestShowCalendar when input receives up arrow event if calendar is already showing', async () => {
- const onRequestShowCalendar = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- await userEvent.type(dateInput, '{arrowup}')
-
- await waitFor(() => {
- expect(onRequestShowCalendar).not.toHaveBeenCalled()
- })
- })
-
- it('should call onRequestShowCalendar when input receives onChange event', async () => {
- const onRequestShowCalendar = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- fireEvent.change(dateInput, { target: { value: 'January 5' } })
-
- await waitFor(() => {
- expect(onRequestShowCalendar).toHaveBeenCalledTimes(1)
- })
- })
+ it('should set required', async () => {
+ const { container } = render(
+
+ )
+ const dateInput = container.querySelector('input')
- it('should not call onRequestShowCalendar when disabled', async () => {
- const onRequestShowCalendar = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- fireEvent.click(dateInput)
-
- await userEvent.type(
- dateInput,
- '{arrowup}{arrowdown}{space}January 5',
- { skipClick: true }
- )
-
- await waitFor(() => {
- expect(onRequestShowCalendar).not.toHaveBeenCalled()
- })
- })
- })
+ expect(dateInput).toHaveAttribute('required')
+ })
- describe('onRequestHideCalendar and onRequestValidateDate', () => {
- it('should call onRequestHideCalendar and onRequestValidateDate input receives onBlur event', async () => {
- const onRequestHideCalendar = vi.fn()
- const onRequestValidateDate = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- fireEvent.blur(dateInput)
-
- await waitFor(() => {
- expect(onRequestHideCalendar).toHaveBeenCalledTimes(1)
- expect(onRequestValidateDate).toHaveBeenCalledTimes(1)
- })
- })
+ it('should call onBlur', async () => {
+ const onBlur = vi.fn()
+ render(
+
+ )
+ const dateInput = screen.getByLabelText('Choose a date')
- it('should call onRequestHideCalendar and onRequestValidateDate when input receives esc event', async () => {
- const onRequestHideCalendar = vi.fn()
- const onRequestValidateDate = vi.fn()
-
- render(
-
- {generateDays()}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- await userEvent.type(dateInput, '{esc}')
-
- await waitFor(() => {
- expect(onRequestHideCalendar).toHaveBeenCalledTimes(1)
- expect(onRequestValidateDate).toHaveBeenCalledTimes(1)
- })
- })
+ fireEvent.blur(dateInput)
- it('should call onRequestHideCalendar and onRequestValidateDate when input receives enter event', async () => {
- const onRequestHideCalendar = vi.fn()
- const onRequestValidateDate = vi.fn()
-
- const days = generateDays()
- days[4] = (
-
- {4}
-
- )
-
- render(
-
- {days}
-
- )
- const dateInput = screen.getByLabelText('Choose date')
-
- await userEvent.type(dateInput, '{enter}')
-
- await waitFor(() => {
- expect(onRequestHideCalendar).toHaveBeenCalledTimes(1)
- expect(onRequestValidateDate).toHaveBeenCalledTimes(1)
- })
- })
+ await waitFor(() => {
+ expect(onBlur).toHaveBeenCalled()
})
+ })
- it('should render calendar navigation label', () => {
- const label = 'January 2019'
+ it('should validate if the invalidDateErrorMessage prop is provided', async () => {
+ const errorMsg = 'errorMsg'
+ const Example = () => {
+ const [inputValue, setInputValue] = useState('')
- render(
+ return (
{label}}
- isShowingCalendar
- >
- {generateDays()}
-
+ renderLabel={LABEL_TEXT}
+ screenReaderLabels={{
+ calendarIcon: 'Calendar',
+ nextMonthButton: 'Next month',
+ prevMonthButton: 'Previous month'
+ }}
+ value={inputValue}
+ onChange={(_e, inputValue, _dateString) => {
+ setInputValue(inputValue)
+ }}
+ invalidDateErrorMessage={errorMsg}
+ />
)
- const navigationLabel = screen.getByTestId('label-id')
-
- expect(navigationLabel).toBeInTheDocument()
- expect(navigationLabel).toHaveTextContent(label)
- })
+ }
- it('should render calendar weekday labels', async () => {
- render(
-
- {generateDays()}
-
- )
- const calendar = await screen.findByRole('listbox')
- const headers = calendar.querySelectorAll('th')
+ render( )
- expect(headers.length).toEqual(7)
+ expect(screen.queryByText(errorMsg)).not.toBeInTheDocument()
- weekdayLabels.forEach((label) => {
- expect(calendar).toHaveTextContent(label)
- })
- })
+ const dateInput = screen.getByLabelText(LABEL_TEXT)
- it('should render all focusable elements in calendar with tabIndex="-1"', async () => {
- render(
- next
- }
- renderPrevMonthButton={
- prev
- }
- isShowingCalendar
- >
- {generateDays()}
-
- )
- const calendar = await screen.findByRole('listbox')
- const calendarDays = calendar.querySelectorAll('button')
- const nextMonthButton = screen.getByTestId('button-next')
- const prevMonthButton = screen.getByTestId('button-prev')
+ await userEvent.click(dateInput)
+ await userEvent.type(dateInput, 'Not a date')
- expect(nextMonthButton).toHaveAttribute('tabIndex', '-1')
- expect(prevMonthButton).toHaveAttribute('tabIndex', '-1')
- expect(calendarDays).toHaveLength(42)
+ dateInput.blur()
- calendarDays.forEach((day) => {
- expect(day).toHaveAttribute('tabIndex', '-1')
- })
+ await waitFor(() => {
+ expect(screen.getByText(errorMsg)).toBeInTheDocument()
})
+ })
- it('should render days with the correct role', async () => {
- const days = generateDays()
- days[5] = (
-
- outside
-
- )
-
- render(
-
- {days}
-
- )
- const calendar = await screen.findByRole('listbox')
- const calendarDays = calendar.querySelectorAll('button')
-
- calendarDays.forEach((day) => {
- if (day.textContent!.includes('outside')) {
- expect(day).toHaveAttribute('role', 'presentation')
- } else {
- expect(day).toHaveAttribute('role', 'option')
- }
- })
- })
+ it('should show form field messages', async () => {
+ const messages: any = [
+ { text: 'TypeLess' },
+ { type: 'error', text: 'Error' },
+ { type: 'success', text: 'Success' },
+ { type: 'hint', text: 'Hint' },
+ { type: 'screenreader-only', text: 'Screenreader' }
+ ]
- it('should assign aria-selected to the selected date and link it to the input', async () => {
- const days = generateDays()
- days[7] = (
-
- selected
-
- )
+ render(
+
+ )
- render(
-
- {days}
-
- )
- const calendar = await screen.findByRole('listbox')
- const calendarDays = calendar.querySelectorAll('button')
- let selectedDayID = ''
-
- calendarDays.forEach((day) => {
- if (day.textContent!.includes('selected')) {
- selectedDayID = day.id
- expect(day).toHaveAttribute('aria-selected', 'true')
- } else {
- expect(day).toHaveAttribute('aria-selected', 'false')
- }
- })
+ expect(screen.getByText('TypeLess')).toBeVisible()
+ expect(screen.getByText('Error')).toBeVisible()
+ expect(screen.getByText('Success')).toBeVisible()
+ expect(screen.getByText('Hint')).toBeVisible()
- const dateInput = screen.getByLabelText('Choose date')
- expect(dateInput).toHaveAttribute('aria-activedescendant', selectedDayID)
- })
+ const screenreaderMessage = screen.getByText('Screenreader')
+ expect(screenreaderMessage).toBeInTheDocument()
+ expect(screenreaderMessage).toHaveClass(/screenReaderContent/)
})
- describe('with minimal config', () => {
- it('should render 44 buttons (a calendar) when clicked', async () => {
- const onChange = vi.fn()
- render(
-
- )
- const dateInput = screen.getByLabelText('Choose date')
+ it('should render date picker dialog with proper role and ARIA label', async () => {
+ const datePickerLabel = 'Date picker'
- fireEvent.click(dateInput)
+ render(
+
+ )
- const calendarTable = document.querySelector('table')
- const calendarDays = calendarTable!.querySelectorAll('button')
- const calendarWrapper = document.querySelector(
- '[id^="Selectable_"][id$="-list"]'
- )
- const calendarButtons = calendarWrapper!.querySelectorAll('button')
+ const calendarButton = screen.getByRole('button', { name: 'Calendar' })
+ await userEvent.click(calendarButton)
- await waitFor(() => {
- expect(calendarButtons).toHaveLength(44)
- expect(calendarDays).toHaveLength(42)
- })
+ await waitFor(() => {
+ const dialog = screen.getByRole('dialog', { name: datePickerLabel })
+ expect(dialog).toBeInTheDocument()
+ expect(dialog).toHaveAttribute('aria-label', datePickerLabel)
})
})
})
diff --git a/packages/ui-date-input/src/DateInput/v2/index.tsx b/packages/ui-date-input/src/DateInput/v2/index.tsx
index 3eb9d46d3f..09bb126980 100644
--- a/packages/ui-date-input/src/DateInput/v2/index.tsx
+++ b/packages/ui-date-input/src/DateInput/v2/index.tsx
@@ -22,477 +22,320 @@
* SOFTWARE.
*/
-import { Children, Component, ReactElement } from 'react'
-
+import { useState, useEffect, forwardRef, ForwardedRef } from 'react'
+import type { SyntheticEvent } from 'react'
import { Calendar } from '@instructure/ui-calendar/latest'
-import type {
- CalendarProps,
- CalendarDayProps
-} from '@instructure/ui-calendar/latest'
-import { CalendarInstUIIcon } from '@instructure/ui-icons'
+import { IconButton } from '@instructure/ui-buttons/latest'
+import {
+ CalendarInstUIIcon,
+ ChevronLeftInstUIIcon,
+ ChevronRightInstUIIcon
+} from '@instructure/ui-icons'
import { Popover } from '@instructure/ui-popover/latest'
-import { Selectable } from '@instructure/ui-selectable'
-import type {
- SelectableProps,
- SelectableRender
-} from '@instructure/ui-selectable'
import { TextInput } from '@instructure/ui-text-input/latest'
-import type { TextInputProps } from '@instructure/ui-text-input/latest'
-import { createChainedFunction } from '@instructure/ui-utils'
-import {
- getInteraction,
- callRenderProp,
- safeCloneElement,
- passthroughProps
-} from '@instructure/ui-react-utils'
-
-import { DateTime, ApplyLocaleContext, Locale } from '@instructure/ui-i18n'
+import { callRenderProp, passthroughProps } from '@instructure/ui-react-utils'
+import { getLocale, getTimezone } from '@instructure/ui-i18n'
-import { withStyle } from '@instructure/emotion'
+import type { DateInputProps } from './props'
+import type { FormMessage } from '@instructure/ui-form-field/latest'
+import type { Moment } from '@instructure/ui-i18n'
+
+function parseLocaleDate(
+ dateString: string = '',
+ locale: string,
+ timeZone: string
+): Date | null {
+ // This function may seem complicated but it basically does one thing:
+ // Given a dateString, a locale and a timeZone. The dateString is assumed to be formatted according
+ // to the locale. So if the locale is `en-us` the dateString is expected to be in the format of M/D/YYYY.
+ // The dateString is also assumed to be in the given timeZone, so "1/1/2020" in "America/Los_Angeles" timezone is
+ // expected to be "2020-01-01T08:00:00.000Z" in UTC time.
+ // This function tries to parse the dateString taking these variables into account and return a javascript Date object
+ // that is adjusted to be in UTC.
+
+ // Split string on '.', whitespace, '/', ',' or '-' using regex: /[.\s/.-]+/.
+ // The '+' allows splitting on consecutive delimiters.
+ // `.filter(Boolean)` is needed because some locales have a delimeter at the end (e.g.: hungarian dates are formatted as `2024. 09. 19.`)
+ const splitDate = dateString.split(/[,.\s/.-]+/).filter(Boolean)
+
+ // create a locale formatted new date to later extract the order and delimeter information
+ const localeDate = new Intl.DateTimeFormat(locale).formatToParts(new Date())
+
+ let index = 0
+ let day: number | undefined,
+ month: number | undefined,
+ year: number | undefined
+ localeDate.forEach((part) => {
+ if (part.type === 'month') {
+ month = parseInt(splitDate[index], 10)
+ index++
+ } else if (part.type === 'day') {
+ day = parseInt(splitDate[index], 10)
+ index++
+ } else if (part.type === 'year') {
+ year = parseInt(splitDate[index], 10)
+ index++
+ }
+ })
+
+ // sensible limitations
+ if (!year || !month || !day || year < 1000 || year > 9999) return null
+
+ // create utc date from year, month (zero indexed) and day
+ const date = new Date(Date.UTC(year, month - 1, day))
+
+ // Format date string in the provided timezone. The locale here is irrelevant, we only care about how to time is adjusted for the timezone.
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ }).formatToParts(date)
+
+ // Extract the date and time parts from the formatted string
+ const dateStringInTimezone: {
+ [key: string]: number
+ } = parts.reduce((acc, part) => {
+ return part.type === 'literal'
+ ? acc
+ : {
+ ...acc,
+ [part.type]: part.value
+ }
+ }, {})
-import generateStyle from './styles'
+ // Create a date string in the format 'YYYY-MM-DDTHH:mm:ss'
+ const dateInTimezone = `${dateStringInTimezone.year}-${dateStringInTimezone.month}-${dateStringInTimezone.day}T${dateStringInTimezone.hour}:${dateStringInTimezone.minute}:${dateStringInTimezone.second}`
-import { allowedProps } from './props'
-import type { DateInputProps, DateInputState } from './props'
-import type { FormMessage } from '@instructure/ui-form-field/latest'
+ // Calculate time difference for timezone offset
+ const timeDiff = new Date(dateInTimezone + 'Z').getTime() - date.getTime()
+ const utcTime = new Date(date.getTime() - timeDiff)
+ // Return the UTC Date corresponding to the time in the specified timezone
+ return utcTime
+}
/**
---
category: components
---
**/
-@withStyle(generateStyle)
-class DateInput extends Component {
- static readonly componentId = 'DateInput'
- static Day = Calendar.Day
- declare context: React.ContextType
- static allowedProps = allowedProps
- static defaultProps = {
- value: '',
- size: 'medium',
- onBlur: () => {}, // must have a default so createChainedFunction works
- isRequired: false,
- isInline: false,
- layout: 'stacked',
- display: 'inline-block',
- placement: 'bottom center',
- isShowingCalendar: false
- }
-
- state = {
- hasInputRef: false,
- isShowingCalendar: false,
- validatedDate: undefined,
- messages: []
- }
- _input?: HTMLInputElement | null = undefined
- ref: Element | null = null
-
- locale(): string {
- if (this.props.locale) {
- return this.props.locale
- } else if (this.context && this.context.locale) {
- return this.context.locale
- }
- return Locale.browserLocale()
- }
+const DateInput = forwardRef(
+ (
+ {
+ renderLabel,
+ screenReaderLabels,
+ isRequired = false,
+ interaction = 'enabled',
+ isInline = false,
+ value,
+ messages,
+ width,
+ onChange,
+ onBlur,
+ withYearPicker,
+ invalidDateErrorMessage,
+ locale,
+ timezone,
+ placeholder,
+ dateFormat,
+ onRequestValidateDate,
+ disabledDates,
+ renderCalendarIcon,
+ margin,
+ inputRef,
+ ...rest
+ }: DateInputProps,
+ ref: ForwardedRef
+ ) => {
+ const userLocale = locale || getLocale()
+ const userTimezone = timezone || getTimezone()
- timezone() {
- if (this.props.timezone) {
- return this.props.timezone
- } else if (this.context && this.context.timezone) {
- return this.context.timezone
- }
- return DateTime.browserTimeZone()
- }
+ const [inputMessages, setInputMessages] = useState(
+ messages || []
+ )
+ const [showPopover, setShowPopover] = useState(false)
- componentDidMount() {
- this.props.makeStyles?.()
- }
+ useEffect(() => {
+ // don't set input messages if there is an internal error set already
+ if (inputMessages.find((m) => m.text === invalidDateErrorMessage)) return
- componentDidUpdate() {
- this.props.makeStyles?.()
- }
+ setInputMessages(messages || [])
+ }, [messages])
- get selectedDateId() {
- let selectedDateId: string | undefined
- Children.toArray(this.props.children).forEach((day) => {
- const { date, isSelected } = (day as ReactElement).props
- if (isSelected) {
- selectedDateId = this.formatDateId(date)
+ useEffect(() => {
+ const [, utcIsoDate] = parseDate(value)
+ // clear error messages if date becomes valid
+ if (utcIsoDate || !value) {
+ setInputMessages(messages || [])
}
- })
- return selectedDateId
- }
-
- get interaction() {
- return getInteraction({ props: this.props })
- }
-
- formatDateId = (date: string) => {
- // ISO8601 strings may contain a space. Remove any spaces before using the
- // date as the id.
- return date.replace(/\s/g, '')
- }
-
- handleInputRef: TextInputProps['inputRef'] = (el) => {
- // Ensures that we position the Calendar with respect to the input correctly
- // if the Calendar is open on mount
- if (!this.state.hasInputRef) {
- this.setState({ hasInputRef: true })
- }
- this._input = el
- this.props.inputRef?.(el)
- }
-
- handleInputChange: TextInputProps['onChange'] = (event, value) => {
- this.props.onChange?.(event, { value })
- this.handleShowCalendar(event)
- }
-
- handleShowCalendar = (event: React.SyntheticEvent) => {
- if (!this.props.children) {
- this.setState({ isShowingCalendar: true })
- } else if (this.interaction === 'enabled' && this.props.children) {
- this.props.onRequestShowCalendar?.(event)
- }
- }
-
- validateDate = (date: string) => {
- const { invalidDateErrorMessage } = this.props
- const disabledDateErrorMessage =
- this.props.disabledDateErrorMessage || invalidDateErrorMessage
- const messages: FormMessage[] = []
- // check if date is enabled
- const { disabledDates } = this.props
- if (
- (typeof disabledDates === 'function' && disabledDates(date)) ||
- (Array.isArray(disabledDates) &&
- disabledDates.find((dateString) =>
- DateTime.parse(dateString, this.locale(), this.timezone()).isSame(
- DateTime.parse(date, this.locale(), this.timezone()),
- 'day'
- )
- ))
- ) {
- messages.push(
- typeof disabledDateErrorMessage === 'function'
- ? disabledDateErrorMessage(date)
- : { type: 'error', text: disabledDateErrorMessage }
- )
- }
-
- // check if date is valid
- if (
- !DateTime.parse(
- date,
- this.locale(),
- this.timezone(),
- [
- DateTime.momentISOFormat,
- 'llll',
- 'LLLL',
- 'lll',
- 'LLL',
- 'll',
- 'LL',
- 'l',
- 'L'
- ],
- true
- ).isValid()
- ) {
- messages.push(
- typeof invalidDateErrorMessage === 'function'
- ? invalidDateErrorMessage(date)
- : { type: 'error', text: invalidDateErrorMessage }
- )
- }
-
- return messages
- }
-
- handleHideCalendar = (event: React.SyntheticEvent, setectedDate?: string) => {
- if (!this.props.children) {
- const dateString = setectedDate || this.props.value
- const messages: FormMessage[] = []
- if (this.props.onRequestValidateDate) {
- const userValidatedDate = this.props.onRequestValidateDate?.(
- event,
- dateString || '',
- this.validateDate(dateString || '')
- )
- messages.push(...(userValidatedDate || []))
- } else {
- if (dateString) {
- messages.push(...this.validateDate(dateString))
+ }, [value])
+
+ const parseDate = (dateString: string = ''): [string, string] => {
+ let date: Date | null = null
+ if (dateFormat) {
+ if (typeof dateFormat === 'string') {
+ // use dateFormat instead of the user locale
+ date = parseLocaleDate(dateString, dateFormat, userTimezone)
+ } else if (dateFormat.parser) {
+ date = dateFormat.parser(dateString)
}
- }
- this.setState({ messages, isShowingCalendar: false })
- } else {
- this.props.onRequestValidateDate?.(event)
- this.props.onRequestHideCalendar?.(event)
- }
- }
-
- handleHighlightOption: SelectableProps['onRequestHighlightOption'] = (
- event,
- { direction }
- ) => {
- const {
- onRequestSelectNextDay,
- onRequestSelectPrevDay,
- onChange,
- value,
- currentDate
- } = this.props
-
- const isValueValid =
- value && DateTime.parse(value, this.locale(), this.timezone()).isValid()
-
- if (direction === -1) {
- if (onRequestSelectPrevDay) {
- onRequestSelectPrevDay?.(event)
} else {
- // @ts-expect-error TODO
- onChange(event, {
- value: DateTime.parse(
- isValueValid ? value : currentDate!,
- this.locale(),
- this.timezone()
- )
- .subtract(1, 'day')
- .format('MMMM D, YYYY')
- })
- this.setState({ messages: [] })
+ // no dateFormat prop passed, use locale for formatting
+ date = parseLocaleDate(dateString, userLocale, userTimezone)
}
+ return date ? [formatDate(date), date.toISOString()] : ['', '']
}
- if (direction === 1) {
- if (onRequestSelectNextDay) {
- onRequestSelectNextDay?.(event)
- } else {
- // @ts-expect-error TODO
- onChange(event, {
- value: DateTime.parse(
- isValueValid ? value : currentDate!,
- this.locale(),
- this.timezone()
- )
- .add(1, 'day')
- .format('MMMM D, YYYY')
- })
- this.setState({ messages: [] })
+
+ const formatDate = (
+ date: Date,
+ timeZone: string = userTimezone
+ ): string => {
+ // use formatter function if provided
+ if (typeof dateFormat !== 'string' && dateFormat?.formatter) {
+ return dateFormat.formatter(date)
}
+ // if dateFormat set to a locale, use that, otherwise default to the user's locale
+ return date.toLocaleDateString(
+ typeof dateFormat === 'string' ? dateFormat : userLocale,
+ {
+ timeZone,
+ calendar: 'gregory',
+ numberingSystem: 'latn'
+ }
+ )
}
- }
-
- renderMonthNavigationButton(type = 'prev') {
- const { renderPrevMonthButton, renderNextMonthButton } = this.props
- const button =
- type === 'prev' ? renderPrevMonthButton : renderNextMonthButton
- return button && safeCloneElement(callRenderProp(button), { tabIndex: -1 })
- }
- renderDays(getOptionProps: SelectableRender['getOptionProps']) {
- const children = this.props.children as ReactElement[]
- if (!children) return
- return Children.map(children, (day) => {
- const { date, isOutsideMonth } = day.props
- const props = { tabIndex: -1, id: this.formatDateId(date) }
- const optionProps = getOptionProps(props)
+ const getDateFormatHint = () => {
+ const exampleDate = new Date('2024-09-01')
+ const formattedDate = formatDate(exampleDate, 'UTC') // exampleDate is in UTC so format it as such
- const propsAdded = isOutsideMonth
- ? {
- ...props,
- onClick: optionProps.onClick,
- role: 'presentation'
- }
- : optionProps
+ // Create a regular expression to find the exact match of the number
+ const regex = (n: string) => {
+ return new RegExp(`(? 'Y'.repeat(match.length))
+ .replace(regex(month), (match) => 'M'.repeat(match.length))
+ .replace(regex(day), (match) => 'D'.repeat(match.length))
+ }
- renderCalendar({
- getListProps,
- getOptionProps
- }: {
- getListProps: SelectableRender['getListProps']
- getOptionProps: SelectableRender['getOptionProps']
- }) {
- const {
- onRequestRenderNextMonth,
- onRequestRenderPrevMonth,
- renderNavigationLabel,
- renderWeekdayLabels,
- value,
- onChange,
- disabledDates,
- currentDate
- } = this.props
+ const handleInputChange = (e: SyntheticEvent, newValue: string) => {
+ const [, utcIsoDate] = parseDate(newValue)
+ onChange?.(e, newValue, utcIsoDate)
+ }
- const isValidDate = value
- ? DateTime.parse(value, this.locale(), this.timezone()).isValid()
- : false
+ const handleDateSelected = (
+ dateString: string,
+ _momentDate: Moment,
+ e: SyntheticEvent
+ ) => {
+ setShowPopover(false)
+ const newValue = formatDate(new Date(dateString))
+ onChange?.(e, newValue, dateString)
+ onRequestValidateDate?.(e, newValue, dateString)
+ }
- const noChildrenProps = this.props.children
- ? {}
- : {
- disabledDates,
- currentDate,
- selectedDate: isValidDate ? value : undefined,
- visibleMonth: isValidDate ? value : undefined,
- onDateSelected: (
- dateString: string,
- momentDate: any,
- e: React.MouseEvent
- ) => {
- // @ts-expect-error TODO
- onChange?.(e, {
- value: `${momentDate.format('MMMM')} ${momentDate.format(
- 'D'
- )}, ${momentDate.format('YYYY')}`
- })
- this.handleHideCalendar(e, dateString)
- }
+ const handleBlur = (e: SyntheticEvent) => {
+ const [localeDate, utcIsoDate] = parseDate(value)
+ if (localeDate) {
+ if (localeDate !== value) {
+ onChange?.(e, localeDate, utcIsoDate)
}
+ } else if (value && invalidDateErrorMessage) {
+ setInputMessages([{ type: 'error', text: invalidDateErrorMessage }])
+ }
+ onRequestValidateDate?.(e, value || '', utcIsoDate)
+ onBlur?.(e, value || '', utcIsoDate)
+ }
- return (
-
- {this.renderDays(getOptionProps)}
-
- )
- }
-
- renderInput({
- getInputProps,
- getTriggerProps
- }: {
- getInputProps: SelectableRender['getInputProps']
- getTriggerProps: SelectableRender['getInputProps']
- }) {
- const {
- renderLabel,
- value,
- placeholder,
- onBlur,
- isRequired,
- size,
- isInline,
- layout,
- width,
- onRequestValidateDate,
- onRequestShowCalendar,
- onRequestHideCalendar,
- onRequestSelectNextDay,
- onRequestSelectPrevDay,
- onRequestRenderNextMonth,
- onRequestRenderPrevMonth,
- ...rest
- } = this.props
-
- const { interaction } = this
+ const selectedDate = parseDate(value)[1]
- const {
- ref, // Apply this to the actual inputRef
- ...triggerProps
- } = getTriggerProps()
- const messages = this.props.messages || this.state.messages
return (
- })}
- onKeyDown={(e) => {
- if (!this.props.children) {
- if (e.key === 'Enter') {
- // @ts-expect-error TODO
- this.handleHideCalendar(e)
+ ref={ref}
+ inputRef={inputRef}
+ renderLabel={renderLabel}
+ onChange={handleInputChange}
+ onBlur={handleBlur}
+ isRequired={isRequired}
+ value={value}
+ placeholder={placeholder ?? getDateFormatHint()}
+ width={width}
+ display={isInline ? 'inline-block' : 'block'}
+ messages={inputMessages}
+ interaction={interaction}
+ margin={margin}
+ renderAfterInput={
+
+ {renderCalendarIcon ? (
+ callRenderProp(renderCalendarIcon)
+ ) : (
+
+ )}
+
}
- }
- triggerProps.onKeyDown?.(e)
- }}
+ isShowingContent={showPopover}
+ onShowContent={() => setShowPopover(true)}
+ onHideContent={() => setShowPopover(false)}
+ on="click"
+ shouldContainFocus
+ shouldReturnFocus
+ shouldCloseOnDocumentClick
+ screenReaderLabel={screenReaderLabels.datePickerDialog}
+ >
+ }
+ screenReaderLabel={screenReaderLabels.nextMonthButton}
+ />
+ }
+ renderPrevMonthButton={
+ }
+ screenReaderLabel={screenReaderLabels.prevMonthButton}
+ />
+ }
+ />
+
+ }
/>
)
}
+)
- shouldShowCalendar = () =>
- this.props.children
- ? this.props.isShowingCalendar
- : this.state.isShowingCalendar
-
- render() {
- const { placement, assistiveText, styles } = this.props
- const isShowingCalendar = this.shouldShowCalendar()
- return (
- this.handleHideCalendar(e)}
- selectedOptionId={this.selectedDateId}
- highlightedOptionId={this.selectedDateId}
- >
- {({
- getRootProps,
- getInputProps,
- getTriggerProps,
- getListProps,
- getOptionProps,
- getDescriptionProps
- }) => (
- {
- this.ref = el
- }}
- data-cid="DateInput"
- >
- {this.renderInput({ getInputProps, getTriggerProps })}
-
- {assistiveText}
-
-
- {this.renderCalendar({ getListProps, getOptionProps })}
-
-
- )}
-
- )
- }
-}
+// TODO this is probably needed?
+DateInput.displayName = 'DateInput'
export default DateInput
export { DateInput }
diff --git a/packages/ui-date-input/src/DateInput/v2/props.ts b/packages/ui-date-input/src/DateInput/v2/props.ts
index 613130bcea..6171c2897e 100644
--- a/packages/ui-date-input/src/DateInput/v2/props.ts
+++ b/packages/ui-date-input/src/DateInput/v2/props.ts
@@ -22,12 +22,10 @@
* SOFTWARE.
*/
-import type { CalendarDayProps } from '@instructure/ui-calendar/latest'
+import type { SyntheticEvent, InputHTMLAttributes } from 'react'
import type { FormMessage } from '@instructure/ui-form-field/latest'
-import type { PlacementPropValues } from '@instructure/ui-position'
-import type { Renderable, OtherHTMLAttributes } from '@instructure/shared-types'
-import type { WithStyleProps, ComponentStyle } from '@instructure/emotion'
-import { InputHTMLAttributes, ReactElement, SyntheticEvent } from 'react'
+import type { OtherHTMLAttributes, Renderable } from '@instructure/shared-types'
+import type { Spacing } from '@instructure/emotion'
type DateInputOwnProps = {
/**
@@ -35,32 +33,39 @@ type DateInputOwnProps = {
*/
renderLabel: Renderable
/**
- * Specifies the input value.
+ * Accessible labels for the calendar button, month navigation buttons, and date picker dialog.
*/
- value?: string // TODO: controllable(PropTypes.string)
+ screenReaderLabels: {
+ calendarIcon: string
+ prevMonthButton: string
+ nextMonthButton: string
+ // TODO: Make this field required in the next version. Currently optional to avoid breaking change.
+ datePickerDialog?: string
+ }
/**
- * Specifies the input size.
+ * Specifies the input value.
*/
- size?: 'small' | 'medium' | 'large'
+ value?: string
/**
- * Html placeholder text to display when the input has no value. This should
- * be hint text, not a label replacement.
+ * Placeholder text for the input field. If it's left undefined it will display a hint for the date format (like `DD/MM/YYYY`).
*/
placeholder?: string
/**
- * Callback executed when the input fires a change event.
- * @param {Object} event - the event object
- * @param {Object} data - additional data
- * @param data.value - the new value
+ * Callback fired when the input changes.
*/
onChange?: (
- event: React.ChangeEvent,
- value: { value: string }
+ event: React.SyntheticEvent,
+ inputValue: string,
+ utcDateString: string
) => void
/**
* Callback executed when the input fires a blur event.
*/
- onBlur?: (event: React.SyntheticEvent) => void
+ onBlur?: (
+ event: React.SyntheticEvent,
+ value: string,
+ utcDateString: string
+ ) => void
/**
* Specifies if interaction with the input is enabled, disabled, or readonly.
* When "disabled", the input changes visibly to indicate that it cannot
@@ -77,28 +82,10 @@ type DateInputOwnProps = {
* is rendered as a block level element.
*/
isInline?: boolean
- /**
- * Additional helpful text to provide to screen readers about the operation
- * of the component.
- */
- assistiveText?: string
- /**
- * Controls the layout. When set to `stacked`, the label rests on top of the
- * input. When set to `inline` the label is next to the input.
- */
- layout?: 'stacked' | 'inline'
/**
* Specifies the width of the input.
*/
width?: string
- /**
- * Specifies the display property of the container.
- */
- display?: 'inline-block' | 'block'
- /**
- * Provides a ref to the underlying input element.
- */
- inputRef?: (element: HTMLInputElement | null) => void
/**
* Displays informational and error messages, used for input validation,
* can also display screenreader-only messages.
@@ -107,112 +94,14 @@ type DateInputOwnProps = {
*/
messages?: FormMessage[]
/**
- * The placement of the calendar in relation to the input.
- */
- placement?: PlacementPropValues
- /**
- * Controls whether the calendar is showing.
- */
- isShowingCalendar?: boolean
- /**
- * Callback fired when the input is blurred. Feedback should be provided
- * to the user when this function is called if the selected date or input
- * value is not valid. The component calculates date validity and if it's
- * disabled or nor and passes that information to this callback.
- */
- onRequestValidateDate?: (
- event: SyntheticEvent,
- dateString?: string,
- validation?: FormMessage[]
- ) => void | FormMessage[]
- /**
- * Callback fired requesting the calendar be shown.
- */
- onRequestShowCalendar?: (event: SyntheticEvent) => void
- /**
- * Callback fired requesting the calendar be hidden.
- */
- onRequestHideCalendar?: (event: SyntheticEvent) => void
- /**
- * Callback fired requesting the next day be selected. If no date is currently
- * selected should default to the first day of the currently rendered month.
- */
- onRequestSelectNextDay?: (event: SyntheticEvent) => void
- /**
- * Callback fired requesting the previous day be selected. If no date is currently
- * selected should default to the first day of the currently rendered month.
- */
- onRequestSelectPrevDay?: (event: SyntheticEvent) => void
- /**
- * Callback fired requesting the next month be rendered.
- */
- onRequestRenderNextMonth?: (e: React.MouseEvent) => void
- /**
- * Callback fired requesting the previous month be rendered.
- */
- onRequestRenderPrevMonth?: (e: React.MouseEvent) => void
- /**
- * Content to render in the calendar navigation header. The recommendation is
- * to include the name of the current rendered month along with the year.
- */
- renderNavigationLabel?: React.ReactNode | (() => React.ReactNode)
- /**
- * An array of labels containing the name of each day of the week. The visible
- * portion of the label should be abbreviated (no longer than three characters).
- * Note that screen readers will read this content preceding each date as the
- * ` ` is navigated. Consider using
- * [AccessibleContent](AccessibleContent) with the `alt` prop containing the
- * full day name for assistive technologies and the children containing the
- * abbreviation. ex. `[Sun , ...]`
- */
- renderWeekdayLabels?: (React.ReactNode | (() => React.ReactNode))[]
- /**
- * A button to render in the calendar navigation header. The recommendation is
- * to compose it with the [Button](Button) component, setting the `variant`
- * prop to `icon`, the `size` prop to `small`, and setting the `icon` prop to
- * [IconArrowOpenEnd](icons).
- */
- renderNextMonthButton?: Renderable
- /**
- * A button to render in the calendar navigation header. The recommendation is
- * to compose it with the [Button](Button) component, setting the `variant`
- * prop to `icon`, the `size` prop to `small`, and setting the `icon` prop to
- * [IconArrowOpenStart](icons).
- */
- renderPrevMonthButton?: Renderable
- /**
- * children of type ` ` There should be exactly 42 provided (6
- * weeks).
- */
- children?: ReactElement[] // TODO: oneOf([Calendar.Day])
- /*
- * Specify which date(s) will be shown as disabled in the calendar.
- * You can either supply an array of ISO8601 timeDate strings or
- * a function that will be called for each date shown in the calendar.
- */
- disabledDates?: string[] | ((isoDateToCheck: string) => boolean)
- /**
- * ISO date string for the current date if necessary. Defaults to the current
- * date in the user's timezone.
- */
- currentDate?: string
- /**
- * 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.
+ * The message shown to the user when the date is invalid. If this prop is not set, validation is bypassed.
+ * If it's set to an empty string, validation happens and the input border changes to red if validation hasn't passed.
**/
- invalidDateErrorMessage?: string | ((rawDateValue: string) => FormMessage)
- /**
- * Error message shown to the user if they enter a date that is disabled.
- * If not specified the component will show the `invalidDateTimeMessage`.
- */
- disabledDateErrorMessage?: string | ((rawDateValue: string) => FormMessage)
+ invalidDateErrorMessage?: string
/**
* A standard language identifier.
*
- * See [Moment.js](https://momentjs.com/timezone/docs/#/using-timezones/parsing-in-zone/) for
+ * 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
@@ -231,7 +120,7 @@ type DateInputOwnProps = {
* 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
+ * The system timezone will be used if no value is set via a component
* property or a context property.
**/
timezone?: string
@@ -249,68 +138,54 @@ type DateInputOwnProps = {
*/
withYearPicker?: {
screenReaderLabel: string
- onRequestYearChange?: (e: any, requestedYear: number) => void
+ onRequestYearChange?: (e: SyntheticEvent, requestedYear: number) => void
startYear: number
endYear: number
}
-}
+ /**
+ * By default the date format is determined by the locale but can be changed via this prop to an alternate locale (passing it in as a string) or a custom parser and formatter (both as functions)
+ */
+ dateFormat?:
+ | {
+ parser: (input: string) => Date | null
+ formatter: (date: Date) => string
+ }
+ | string
-type PropKeys = keyof DateInputOwnProps
+ /**
+ * Callback executed when the input fires a blur event or a date is selected from the picker.
+ */
+ onRequestValidateDate?: (
+ event: React.SyntheticEvent,
+ value: string,
+ utcDateString: string
+ ) => void
-type AllowedPropKeys = Readonly>
+ /**
+ * Custom icon for the icon button opening the picker.
+ */
+ renderCalendarIcon?: Renderable
+
+ /**
+ * Margin around the component. Accepts a `Spacing` token. See token values and example usage in [this guide](https://instructure.design/#layout-spacing).
+ */
+ margin?: Spacing
+ /*
+ * Specify which date(s) will be shown as disabled in the calendar.
+ * You can either supply an array of ISO8601 timeDate strings or
+ * a function that will be called for each date shown in the calendar.
+ */
+ disabledDates?: string[] | ((isoDateToCheck: string) => boolean)
+
+ /**
+ * A function that provides a reference to the inner input element
+ */
+ inputRef?: (inputElement: HTMLInputElement | null) => void
+}
type DateInputProps = DateInputOwnProps &
- WithStyleProps &
OtherHTMLAttributes<
DateInputOwnProps,
InputHTMLAttributes
>
-
-type DateInputStyle = ComponentStyle<'dateInput' | 'assistiveText'>
-const allowedProps: AllowedPropKeys = [
- 'renderLabel',
- 'value',
- 'size',
- 'placeholder',
- 'onChange',
- 'onBlur',
- 'interaction',
- 'isRequired',
- 'isInline',
- 'assistiveText',
- 'layout',
- 'width',
- 'display',
- 'inputRef',
- 'messages',
- 'placement',
- 'isShowingCalendar',
- 'onRequestValidateDate',
- 'onRequestShowCalendar',
- 'onRequestHideCalendar',
- 'onRequestSelectNextDay',
- 'onRequestSelectPrevDay',
- 'onRequestRenderNextMonth',
- 'onRequestRenderPrevMonth',
- 'renderNavigationLabel',
- 'renderWeekdayLabels',
- 'renderNextMonthButton',
- 'renderPrevMonthButton',
- 'children',
- 'disabledDates',
- 'currentDate',
- 'disabledDateErrorMessage',
- 'invalidDateErrorMessage',
- 'locale',
- 'timezone'
-]
-
-type DateInputState = {
- hasInputRef: boolean
- isShowingCalendar: boolean
- validatedDate?: string
- messages: FormMessage[]
-}
-
-export type { DateInputProps, DateInputStyle, DateInputState }
-export { allowedProps }
+export type { DateInputProps }
diff --git a/packages/ui-date-input/src/DateInput/v2/styles.ts b/packages/ui-date-input/src/DateInput/v2/styles.ts
deleted file mode 100644
index 2681667d1c..0000000000
--- a/packages/ui-date-input/src/DateInput/v2/styles.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * 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 type { DateInputProps, DateInputStyle } from './props'
-
-/**
- * ---
- * private: true
- * ---
- * Generates the style object from the theme and provided additional information
- * @param _componentTheme The theme variable object.
- * @param props the props of the component, the style is applied to
- * @return The final style object, which will be used in the component
- */
-const generateStyle = (
- _componentTheme: null,
- props: DateInputProps
-): DateInputStyle => {
- return {
- dateInput: {
- label: 'dateInput',
- display: props.display
- },
- assistiveText: {
- label: 'dateInput__assistiveText',
- display: 'none'
- }
- }
-}
-
-export default generateStyle
diff --git a/packages/ui-date-input/src/DateInput2/v1/README.md b/packages/ui-date-input/src/DateInput2/v1/README.md
index a6cea89ab9..781ce5951f 100644
--- a/packages/ui-date-input/src/DateInput2/v1/README.md
+++ b/packages/ui-date-input/src/DateInput2/v1/README.md
@@ -2,7 +2,15 @@
describes: DateInput2
---
-> _Info_: `DateInput2` is an upgrade to the [`DateInput`](/#DateInput) component, offering easier configuration, better UX, improved accessibility, and a year picker. Please consider updating to this for WCAG compatiblity and an overall better experience (for both devs and users).
+> **Deprecated:** `DateInput2` is deprecated and will not receive further updates. Its functionality has been merged into the latest version of [`DateInput`](/v11_7/DateInput) which has the same API. Please migrate to `DateInput` for continued support.
+
+### DateInput versions at a glance
+
+| Version | API | Theming | Accessibility | Status |
+| :------------------------------------------- | :--------------------------- | :--------------- | :-------------- | :-------------- |
+| [v11.6 DateInput](/v11_6/DateInput) | Old (manual calendar wiring) | Old theming only | Has a11y issues | **Deprecated** |
+| [v11.6 DateInput2](/v11_6/DateInput2) (this) | New (simple) | Old theming only | Good | **Deprecated** |
+| [v11.7 DateInput](/v11_7/DateInput) | New (simple) | New theming | Good | **Recommended** |
### Minimal config
diff --git a/regression-test/src/app/dateinput/page.tsx b/regression-test/src/app/dateinput/page.tsx
index 6520dfd071..8c3f1669ff 100644
--- a/regression-test/src/app/dateinput/page.tsx
+++ b/regression-test/src/app/dateinput/page.tsx
@@ -24,17 +24,12 @@
'use client'
import React, { useState } from 'react'
-import {
- DateInput as di,
- DateInput2 as di2,
- IconAddLine
-} from '@instructure/ui/latest'
+import { DateInput as di, IconAddLine } from '@instructure/ui/latest'
const DateInput = di as any
-const DateInput2 = di2 as any
export default function DateInputExamplesPage() {
- // DateInput2 states
+ // DateInput states
const [di2Value, setDi2Value] = useState('')
const [di2DateString, setDi2DateString] = useState('')
const [di2Value2, setDi2Value2] = useState('')
@@ -45,9 +40,9 @@ export default function DateInputExamplesPage() {
return (
- DateInput2:
+ DateInput:
- }
@@ -72,9 +67,9 @@ export default function DateInputExamplesPage() {
- {/* DateInput2 - with year picker */}
+ {/* DateInput - with year picker */}
- {/* DateInput2 - disabled dates */}
+ {/* DateInput - disabled dates */}
-
-
- DateInput:
- {
- setDiValue(value)
- // rudimentary validation example: flag very short values
- if (value && value.length < 4) {
- setDiMessages([{ type: 'error', text: 'This date is invalid' }])
- } else {
- setDiMessages([])
- }
- }}
- messages={diMessages}
- width="40rem"
- isInline
- />
-
)
}