diff --git a/cypress/component/DateTimeInput.cy.tsx b/cypress/component/DateTimeInput.cy.tsx
index b7cf45616c..53207f9a21 100644
--- a/cypress/component/DateTimeInput.cy.tsx
+++ b/cypress/component/DateTimeInput.cy.tsx
@@ -39,8 +39,11 @@ describe('', () => {
cy.mount(
', () => {
cy.contains('time-input label')
cy.contains('Thursday, January 18, 2018 1:30 PM')
- cy.get('input[id^="Selectable_"]').as('dateInput')
+ cy.get('input[id^="TextInput_"]').as('dateInput')
cy.get('input[id^="Select_"]').as('timeInput')
- cy.get('@dateInput').should('have.value', 'January 18, 2018')
+ cy.get('@dateInput').should('have.value', '1/18/2018')
cy.get('@timeInput').should('have.value', '1:30 PM')
cy.get('@dateInput').clear().blur()
@@ -67,32 +70,29 @@ describe('', () => {
cy.get('@timeInput').should('have.value', '')
cy.wrap(onChange).should('have.been.called')
- cy.get('@dateInput').realClick().wait(100)
+ cy.contains('button', 'Choose date').realClick().wait(100)
+
+ cy.contains('button', '22').realClick().wait(100)
- cy.contains('button', '22')
- .realClick()
- .wait(100)
- .then(($btn) => {
- const selectedDateId = $btn.attr('id')!
- const selectedDateValue = DateTime.parse(
- selectedDateId,
+ cy.wrap(onChange)
+ .should('have.been.called')
+ .then((spy) => {
+ const selectedDateId: string = spy.lastCall.args[1]
+ const selectedDateValue = new Date(selectedDateId).toLocaleDateString(
'en-US',
- 'US/Eastern'
- ).format('LL')
+ {
+ timeZone: 'US/Eastern',
+ calendar: 'gregory',
+ numberingSystem: 'latn'
+ }
+ )
cy.get('@dateInput').should('have.value', selectedDateValue)
cy.get('@timeInput').should('have.value', '4:16 PM')
- cy.wrap(onChange)
- .should('have.been.called')
- .then((spy) => {
- const lastCallFirstArg = spy.lastCall.args[1]
-
- const lastCallDatePart = lastCallFirstArg.split('T')[0]
- const expectedDatePart = selectedDateId.split('T')[0]
+ const lastCallDatePart = selectedDateId.split('T')[0]
- expect(lastCallDatePart).to.equal(expectedDatePart)
- })
+ expect(lastCallDatePart).to.include('-22')
})
})
@@ -103,8 +103,11 @@ describe('', () => {
', () => {
onChange={onChange}
/>
)
- cy.get('input[id^="Selectable_"]').as('dateInput')
+ cy.get('input[id^="TextInput_"]').as('dateInput')
cy.get('@dateInput').realClick().wait(100)
cy.get('@dateInput').type('Not a date{enter}')
cy.get('@dateInput').blur()
@@ -128,8 +131,11 @@ describe('', () => {
cy.mount(
', () => {
disabledDateTimeMessage={errorMsg}
/>
)
- cy.get('input[id^="Selectable_"]').as('dateInput')
+ cy.get('input[id^="TextInput_"]').as('dateInput')
cy.get('body').should('contain', errorMsg)
- cy.get('@dateInput').clear().type(`05/18/2017{enter}`)
+ cy.get('@dateInput').clear().type(`05/18/2017`).blur()
cy.get('body').should('not.contain', errorMsg)
})
@@ -160,8 +166,11 @@ describe('', () => {
cy.mount(
', () => {
disabledDateTimeMessage={errorMsg}
/>
)
- cy.get('input[id^="Selectable_"]').as('dateInput')
+ cy.get('input[id^="TextInput_"]').as('dateInput')
cy.get('body').should('contain', errorMsgText)
- cy.get('@dateInput').clear().type(`May 18, 2022{enter}`)
+ cy.get('@dateInput').clear().type(`May 18, 2022`).blur()
cy.get('body').should('not.contain', errorMsgText)
})
@@ -187,8 +196,11 @@ describe('', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
locale,
@@ -197,10 +209,10 @@ describe('', () => {
cy.mount()
- cy.get('input[id^="Selectable_"]').as('dateInput')
+ cy.get('input[id^="TextInput_"]').as('dateInput')
cy.get('input[id^="Select_"]').as('timeInput')
- cy.get('@dateInput').should('have.value', 'May 1, 2017')
+ cy.get('@dateInput').should('have.value', '5/1/2017')
cy.get('@timeInput').should('have.value', '1:30 PM')
cy.get('body').should('contain', 'May 1, 2017 1:30 PM')
@@ -215,11 +227,11 @@ describe('', () => {
const newDateStr = '2022-03-29T19:00Z'
cy.mount()
- cy.get('@dateInput').should('have.value', 'March 29, 2022')
+ cy.get('@dateInput').should('have.value', '3/29/2022')
cy.get('@timeInput').should('have.value', '3:00 PM')
cy.get('body').should('contain', 'March 29, 2022 3:00 PM')
- cy.get('@dateInput').clear().type('{esc}')
+ cy.get('@dateInput').clear().blur()
cy.get('@dateInput').should('have.value', '')
cy.get('@timeInput').should('have.value', '')
@@ -234,8 +246,11 @@ describe('', () => {
', () => {
allowNonStepInput={true}
/>
)
- cy.get('input[id^="Selectable_"]').as('dateInput')
+ cy.get('input[id^="TextInput_"]').as('dateInput')
cy.get('input[id^="Select_"]').as('timeInput')
cy.get('@timeInput').clear().type(`7:34 PM`)
- cy.get('@dateInput').clear().type(`May 1, 2017{enter}`)
+ cy.get('@dateInput').clear().type(`May 1, 2017`).blur()
cy.wrap(onChange)
.should('have.been.called')
@@ -268,8 +283,11 @@ describe('', () => {
', () => {
initialTimeForNewDate="05:05"
/>
)
- cy.get('input[id^="Selectable_"]').as('dateInput')
+ cy.get('input[id^="TextInput_"]').as('dateInput')
cy.get('input[id^="Select_"]').as('timeInput')
cy.get('@dateInput').clear().type(`May 1, 2017{enter}`)
diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md
index 033be9757a..14fe623bb1 100644
--- a/docs/guides/upgrade-guide.md
+++ b/docs/guides/upgrade-guide.md
@@ -402,6 +402,54 @@ Now that InstUI supports component versioning, we no longer need the separate `D
- **[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.
+### DateTimeInput
+
+
+
+**Removed props:**
+
+| Removed prop | Replacement |
+| --------------------- | ------------------------------------ |
+| `renderWeekdayLabels` | Built in — no replacement needed |
+| `prevMonthLabel` | `screenReaderLabels.prevMonthButton` |
+| `nextMonthLabel` | `screenReaderLabels.nextMonthButton` |
+
+**Changed props:**
+
+| Prop | old API | new API |
+| ------------ | ------------------------------------- | --------------------------------------------------------- |
+| `dateFormat` | Moment.js format string (e.g. `'LL'`) | Locale string (e.g. `'en-US'`) or `{ parser, formatter }` |
+
+If you were passing a Moment format string like `dateFormat="LL"`, replace it with a locale string or a custom `{ parser, formatter }` object. If you were relying on the default `'LL'` format, note that v2 now uses the locale's default date format (e.g. `1/18/2018` in `en-US`) instead of the long format (e.g. `January 18, 2018`). To preserve the long format, pass a custom `{ parser, formatter }` object.
+
+**New props:**
+
+| New prop | Description |
+| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `screenReaderLabels` | **Required.** Object containing accessible labels: `calendarIcon`, `prevMonthButton`, `nextMonthButton`, `datePickerDialog?`. |
+| `withYearPicker` | Optional. Enables a year dropdown in the calendar. |
+
+```js
+---
+type: code
+---
+// old API
+
+
+// new API
+
+```
+
### DataPermissionLevels
```js
diff --git a/packages/ui-date-time-input/package.json b/packages/ui-date-time-input/package.json
index fc4ed87788..8e0a26d5d7 100644
--- a/packages/ui-date-time-input/package.json
+++ b/packages/ui-date-time-input/package.json
@@ -72,18 +72,18 @@
"default": "./es/exports/a.js"
},
"./v11_7": {
- "src": "./src/exports/a.ts",
- "types": "./types/exports/a.d.ts",
- "import": "./es/exports/a.js",
- "require": "./lib/exports/a.js",
- "default": "./es/exports/a.js"
+ "src": "./src/exports/b.ts",
+ "types": "./types/exports/b.d.ts",
+ "import": "./es/exports/b.js",
+ "require": "./lib/exports/b.js",
+ "default": "./es/exports/b.js"
},
"./latest": {
- "src": "./src/exports/a.ts",
- "types": "./types/exports/a.d.ts",
- "import": "./es/exports/a.js",
- "require": "./lib/exports/a.js",
- "default": "./es/exports/a.js"
+ "src": "./src/exports/b.ts",
+ "types": "./types/exports/b.d.ts",
+ "import": "./es/exports/b.js",
+ "require": "./lib/exports/b.js",
+ "default": "./es/exports/b.js"
}
}
}
diff --git a/packages/ui-date-time-input/src/DateTimeInput/__tests__/DateTimeInput.test.tsx b/packages/ui-date-time-input/src/DateTimeInput/__tests__/DateTimeInput.test.tsx
index deb1e627a1..1ab4a2a562 100644
--- a/packages/ui-date-time-input/src/DateTimeInput/__tests__/DateTimeInput.test.tsx
+++ b/packages/ui-date-time-input/src/DateTimeInput/__tests__/DateTimeInput.test.tsx
@@ -56,8 +56,11 @@ describe('', () => {
render(
', () => {
const dateInput = screen.getByLabelText('date-input')
const timeInput = screen.getByLabelText('time-input')
- expect(dateInput).toHaveValue(dateTime.format('LL'))
- expect(timeInput).toHaveValue(dateTime.format('LT'))
+ expect(dateInput).toHaveValue('5/1/2017')
+ expect(timeInput).toHaveValue('1:30 PM')
})
it('should use the value', () => {
@@ -82,8 +85,11 @@ describe('', () => {
render(
', () => {
const dateInput = screen.getByLabelText('date-input')
const timeInput = screen.getByLabelText('time-input')
- expect(dateInput).toHaveValue(dateTime.format('LL'))
- expect(timeInput).toHaveValue(dateTime.format('LT'))
+ expect(dateInput).toHaveValue('5/1/2017')
+ expect(timeInput).toHaveValue('7:30 PM')
})
it('should prefer value to defaultValue', () => {
@@ -110,8 +116,11 @@ describe('', () => {
render(
', () => {
const dateInput = screen.getByLabelText('date-input')
const timeInput = screen.getByLabelText('time-input')
- expect(dateInput).toHaveValue(value.format('LL'))
- expect(timeInput).toHaveValue(value.format('LT'))
+ expect(dateInput).toHaveValue('5/1/2017')
+ expect(timeInput).toHaveValue('1:30 PM')
})
it('should set time to local midnight when only date is set', () => {
@@ -139,8 +148,11 @@ describe('', () => {
', () => {
const dateInput = screen.getByLabelText('date-input')
const timeInput = screen.getByLabelText('time-input')
- expect(dateInput).toHaveValue(dateObj.format('LL'))
+ expect(dateInput).toHaveValue('4/1/2017')
expect(timeInput).toHaveValue('12:00 AM')
})
@@ -162,8 +174,11 @@ describe('', () => {
', () => {
', () => {
', () => {
', () => {
', () => {
', () => {
', () => {
const props = {
description: 'date_time',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
dateRenderLabel: 'date',
timeRenderLabel: 'time',
invalidDateTimeMessage: 'whoops',
@@ -372,8 +408,11 @@ describe('', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
locale,
@@ -397,8 +436,11 @@ describe('', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
locale,
@@ -419,8 +461,11 @@ describe('', () => {
', () => {
', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
locale,
@@ -488,8 +539,11 @@ describe('', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
locale,
@@ -521,8 +575,11 @@ describe('', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
locale,
@@ -575,8 +632,11 @@ describe('', () => {
', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
value: dateTime.toISOString(),
onChange
}
- const { rerender } = render(
+ // locale string: French format gives DD/MM/YYYY
+ const { rerender } = render()
+ const dateInput = screen.getByLabelText('date-input')
+ expect(dateInput).toHaveValue('01/05/2017')
+
+ // custom { parser, formatter } object
+ rerender(
{
+ const [y, m, d] = input.split('/').map(Number)
+ return isNaN(y) ? null : new Date(Date.UTC(y, m - 1, d))
+ },
+ formatter: (date) =>
+ `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(
+ 2,
+ '0'
+ )}/${String(date.getUTCDate()).padStart(2, '0')}`
+ }}
/>
)
- const dateInput = screen.getByLabelText('date-input')
-
- expect(dateInput).toHaveValue('5/1/2017')
-
- rerender()
-
- fireEvent.blur(screen.getByLabelText('date-input'))
await waitFor(() => {
- expect(dateInput).toHaveValue('2017 May')
+ expect(dateInput).toHaveValue('2017/05/01')
})
})
@@ -627,8 +699,11 @@ describe('', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
value: dateTime.toISOString(),
@@ -655,8 +730,11 @@ describe('', () => {
const props = {
description: 'date_time',
dateRenderLabel: 'date-input',
- prevMonthLabel: 'Previous month',
- nextMonthLabel: 'Next month',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
timeRenderLabel: 'time-input',
invalidDateTimeMessage: 'whoops',
locale,
@@ -667,7 +745,6 @@ describe('', () => {
expect(container).toHaveTextContent('May 1, 2017 1:30 PM')
const newDateStr = '2022-03-29T19:00Z'
- const newDateTime = DateTime.parse(newDateStr, locale, timezone)
rerender()
const dateInput = screen.getByLabelText('date-input')
@@ -676,8 +753,8 @@ describe('', () => {
fireEvent.blur(dateInput)
await waitFor(() => {
- expect(dateInput).toHaveValue(newDateTime.format('LL'))
- expect(timeInput).toHaveValue(newDateTime.format('LT'))
+ expect(dateInput).toHaveValue('3/29/2022')
+ expect(timeInput).toHaveValue('3:00 PM')
})
})
@@ -688,8 +765,11 @@ describe('', () => {
', () => {
', () => {
', () => {
expect(timeInput).toHaveValue('1:30 PM')
expect(container).toHaveTextContent('Thursday, January 18, 2018 1:30 PM')
})
+
+ it('should parse ISO format date typed into the date input', async () => {
+ // In v1, DateTimeInput parsed typed text using moment with the full default format
+ // list which included ISO_8601. In v2 the parser only uses [this.props.dateFormat]
+ // ("LL" by default) as a hint. This test verifies that typing an ISO date string
+ // still works correctly after the switch to DateInput v2.
+ const onChange = vi.fn()
+ render(
+
+ )
+ const dateInput = screen.getByLabelText('date-input')
+
+ await userEvent.type(dateInput, '2017-05-01')
+ fireEvent.blur(dateInput)
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalled()
+ expect(onChange.mock.calls[0][1]).toContain('2017-05-01')
+ expect(dateInput).toHaveValue('5/1/2017')
+ })
+ })
+
+ it('should preserve the selected time when a new date is picked from the calendar', async () => {
+ // In v1, calendar day clicks went through handleDayClick which called
+ // recalculateState directly in a single setState. In v2, DateInput fires
+ // onChange (→ handleDateTextChange) then onRequestValidateDate (→ handleDateValidated)
+ // synchronously, resulting in two setState calls. This means handleDateValidated
+ // reads this.state.timeSelectValue from the pre-update state. This test verifies
+ // that React 18 batching keeps the state consistent and the existing time is
+ // correctly preserved when the user picks a new date from the calendar.
+ const onChange = vi.fn()
+ const locale = 'en-US'
+ const timezone = 'US/Eastern'
+
+ render(
+
+ )
+
+ const calendarButton = screen.getByRole('button', { name: 'Open calendar' })
+ await userEvent.click(calendarButton)
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: '15 May 2017' })
+ ).toBeInTheDocument()
+ })
+ await userEvent.click(screen.getByRole('button', { name: '15 May 2017' }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('date-input')).toHaveValue('5/15/2017')
+ expect(screen.getByLabelText('time-input')).toHaveValue('1:30 PM')
+ expect(onChange).toHaveBeenCalled()
+ expect(onChange.mock.calls[0][1]).toContain('2017-05-15')
+ })
+ })
+
+ it('should normalize the typed date and emit the correct ISO on blur', async () => {
+ // DateInput v2 introduced input normalization on blur, which did not exist in v1.
+ // If the user types a valid but non-canonical string (e.g. "May 1 2017" without
+ // comma), DateInput v2 fires an extra onChange with the normalized form ("May 1, 2017")
+ // before onRequestValidateDate. DateTimeInput must handle this correctly: the
+ // displayed value should be normalized and onChange should fire with the correct ISO.
+ const onChange = vi.fn()
+ render(
+
+ )
+ const dateInput = screen.getByLabelText('date-input')
+
+ await userEvent.type(dateInput, 'May 1 2017')
+ fireEvent.blur(dateInput)
+
+ await waitFor(() => {
+ expect(dateInput).toHaveValue('5/1/2017')
+ expect(onChange).toHaveBeenCalled()
+ expect(onChange.mock.calls[0][1]).toContain('2017-05-01')
+ })
+ })
+
+ describe('dateFormat parsing regression from v1: formats v1 accepted but v2 rejects', () => {
+ // DateTimeInput v1 used DateInput v1 which tried many moment formats:
+ // [ISO, 'llll', 'LLLL', 'lll', 'LLL', 'll', 'LL', 'l', 'L'].
+ // DateTimeInput v2 uses a custom parser that only tries [momentISOFormat, dateFormat]
+ // (default 'LL'). The tests below document the regression: these inputs were
+ // accepted in v1 but are currently rejected in v2. They should pass once fixed.
+ const renderComponent = (onChange = vi.fn()) =>
+ render(
+
+ )
+
+ it('should accept abbreviated month name (ll format: "Sep 4, 1986")', async () => {
+ const onChange = vi.fn()
+ renderComponent(onChange)
+ const dateInput = screen.getByLabelText('date-input')
+
+ await userEvent.type(dateInput, 'Sep 4, 1986')
+ fireEvent.blur(dateInput)
+
+ await waitFor(() => {
+ expect(screen.queryByText('whoops')).not.toBeInTheDocument()
+ expect(onChange).toHaveBeenCalled()
+ expect(onChange.mock.calls[0][1]).toContain('1986-09-04')
+ })
+ })
+
+ it('should accept numeric date with leading zeros (L format: "09/04/1986")', async () => {
+ const onChange = vi.fn()
+ renderComponent(onChange)
+ const dateInput = screen.getByLabelText('date-input')
+
+ await userEvent.type(dateInput, '09/04/1986')
+ fireEvent.blur(dateInput)
+
+ await waitFor(() => {
+ expect(screen.queryByText('whoops')).not.toBeInTheDocument()
+ expect(onChange).toHaveBeenCalled()
+ expect(onChange.mock.calls[0][1]).toContain('1986-09-04')
+ })
+ })
+
+ it('should accept numeric date without leading zeros (l format: "9/4/1986")', async () => {
+ const onChange = vi.fn()
+ renderComponent(onChange)
+ const dateInput = screen.getByLabelText('date-input')
+
+ await userEvent.type(dateInput, '9/4/1986')
+ fireEvent.blur(dateInput)
+
+ await waitFor(() => {
+ expect(screen.queryByText('whoops')).not.toBeInTheDocument()
+ expect(onChange).toHaveBeenCalled()
+ expect(onChange.mock.calls[0][1]).toContain('1986-09-04')
+ })
+ })
+
+ it('should accept date with time component (LLL format: "September 4, 1986 8:30 PM")', async () => {
+ const onChange = vi.fn()
+ renderComponent(onChange)
+ const dateInput = screen.getByLabelText('date-input')
+
+ await userEvent.type(dateInput, 'September 4, 1986 8:30 PM')
+ fireEvent.blur(dateInput)
+
+ await waitFor(() => {
+ expect(screen.queryByText('whoops')).not.toBeInTheDocument()
+ expect(onChange).toHaveBeenCalled()
+ expect(onChange.mock.calls[0][1]).toContain('1986-09-05')
+ })
+ })
+ })
+
+ it('should render the year picker in the calendar when withYearPicker is set', async () => {
+ render(
+
+ )
+
+ const calendarButton = screen.getByRole('button', { name: 'Open calendar' })
+ await userEvent.click(calendarButton)
+
+ await waitFor(() => {
+ const yearPicker = screen.getByRole('combobox', {
+ description: 'Pick a year'
+ })
+ expect(yearPicker).toBeInTheDocument()
+ expect(yearPicker).toHaveValue('2017')
+ })
+ })
+
+ it('should call consumer onChange exactly once when typing a short-format date and blurring', async () => {
+ const onChange = vi.fn()
+ render(
+
+ )
+ const dateInput = screen.getByLabelText('date-input')
+
+ await userEvent.type(dateInput, '9/1/2017')
+ fireEvent.blur(dateInput)
+
+ await waitFor(() => {
+ expect(dateInput).toHaveValue('9/1/2017')
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange.mock.calls[0][1]).toContain('2017-09-01')
+ })
+ })
+
+ describe('date input placeholder', () => {
+ const defaultProps = {
+ description: 'date_time',
+ screenReaderLabels: {
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ },
+ dateRenderLabel: 'date-input',
+ timeRenderLabel: 'time-input',
+ invalidDateTimeMessage: 'whoops'
+ } as const
+
+ it('uses datePlaceholder when provided', () => {
+ render(
+
+ )
+ expect(screen.getByLabelText('date-input')).toHaveAttribute(
+ 'placeholder',
+ 'Pick a date'
+ )
+ })
+
+ it('shows built-in format hint when datePlaceholder is omitted', () => {
+ render(
+
+ )
+ expect(
+ screen.getByLabelText('date-input').getAttribute('placeholder')
+ ).toBeTruthy()
+ })
+ })
})
diff --git a/packages/ui-date-time-input/src/DateTimeInput/v2/README.md b/packages/ui-date-time-input/src/DateTimeInput/v2/README.md
new file mode 100644
index 0000000000..864c929d9d
--- /dev/null
+++ b/packages/ui-date-time-input/src/DateTimeInput/v2/README.md
@@ -0,0 +1,261 @@
+---
+describes: DateTimeInput
+---
+
+A DateTimeInput component is used to enter a date-time value. It is built as a composition
+of the [DateInput](DateInput) and [TimeSelect](TimeSelect) components.
+
+The properties of DateTimeInput prefaced with _date_ are passed to the underlying [DateInput](DateInput)
+(e.g. _dateLabel_ is DateInput's _label_), while the properties prefaced
+with _time_ are forwarded to the underlying
+[TimeSelect](TimeSelect). Others are either shared by both sub-components (e.g. locale), or
+are unique to DateTimeInput (e.g. description).
+
+### Localization
+
+The component is localized via its `locale` and `timezone` parameters. Both are read from `props`, `context` and from the browser's locale in this priority order. `locale` determines the language and format dates and time are displayed in (e.g. month names, AM/PM or 24-hour format) and the beginning of the week (e.g. Monday in Germany, Sunday in the U.S.) in the dropdown calendar.
+
+### Examples
+
+#### A DateTimeInput with `columns` layout and a default value:
+
+```js
+---
+type: example
+---
+ const Example = () => {
+ return (
+
+
+
+ )
+ }
+
+ render()
+```
+
+#### A required DateInput with `stacked` layout that warns if the value in the past:
+
+This sample code also allows the user to enter an arbitrary time value by setting `allowNonStepInput` to `true`.
+
+```js
+---
+type: example
+---
+ const Example = () => {
+ const [value, setValue] = useState(undefined)
+ const [messages, setMessages] = useState([])
+
+ const onChange = (e, isoDate) => {
+ let newMessages = []
+ if (!isoDate) {
+ // this happens if an invalid date is entered
+ setValue(undefined)
+ setMessages(newMessages)
+ return
+ }
+ const now = new Date()
+ const newValue = new Date(isoDate)
+ if (newValue.valueOf() <= now.valueOf()) {
+ newMessages = [{ text: 'That date-time is in the past', type: 'hint' }]
+ }
+ setValue(isoDate)
+ setMessages(newMessages)
+ }
+
+ const text = value ? new Date(value).toString() : 'N/A'
+
+ return (
+
+ )
+ }
+
+ render()
+```
+
+#### A disabled DateTimeInput:
+
+```js
+---
+type: example
+---
+ { return `'${dvalue} is not valid.` }}
+ layout="columns"
+ defaultValue="2018-01-18T13:30"
+ interaction="disabled"
+/>
+```
+
+#### A DateTimeInput in a different locale and timezone where these are set from the React `context`:
+
+```js
+---
+type: example
+---
+ const Example = () => {
+ return (
+
+
+ {
+ return `'${dvalue} is not valid.`
+ }}
+ layout="columns"
+ defaultValue="2018-01-18T16:00"
+ />
+
+
+ )
+ }
+
+ render()
+```
+
+#### A `DateTimeInput` with some disabled dates that are supplied via a `string` array:
+
+```js
+---
+type: example
+---
+
+```
+
+#### A `DateTimeInput` with some disabled dates that are supplied via a `function`:
+
+```js
+---
+type: example
+---
+ const Example = () => {
+ const locale = 'en-us'
+ const timezone = 'America/Denver'
+
+ const getDisabledDates = (isoDateToCheck) => {
+ const parsed = moment.tz(
+ isoDateToCheck,
+ [moment.ISO_8601],
+ locale,
+ timezone
+ )
+ const now = moment().locale(locale).tz(timezone)
+ return parsed.isBefore(now)
+ }
+
+ return (
+
+ )
+ }
+
+ render()
+```
+
+#### Programatically reset `DateTimeInput`
+
+Due to `onChange` not being called on every typing event, and `value` isn't representing the inner value-state of the component, it's not possible to reset the `DateTimeInput` by setting the `value` to `undefined`. Instead, you can use the `reset` function that is passed to the `DateTimeInput` as a prop.
+
+```js
+---
+type: example
+---
+ const Example = () => {
+ const [date, setDate] = useState('')
+ const resetFn = useRef()
+
+ return (
+
+ )
+ }
+ render()
+```
diff --git a/packages/ui-date-time-input/src/DateTimeInput/v2/index.tsx b/packages/ui-date-time-input/src/DateTimeInput/v2/index.tsx
new file mode 100644
index 0000000000..3792bae542
--- /dev/null
+++ b/packages/ui-date-time-input/src/DateTimeInput/v2/index.tsx
@@ -0,0 +1,439 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2018 - 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 { Component, SyntheticEvent } from 'react'
+import { Locale, DateTime, ApplyLocaleContext } from '@instructure/ui-i18n'
+import type { Moment } from '@instructure/ui-i18n'
+import { FormFieldGroup } from '@instructure/ui-form-field/latest'
+import type { FormMessage } from '@instructure/ui-form-field/latest'
+
+import { DateInput } from '@instructure/ui-date-input/latest'
+import { TimeSelect } from '@instructure/ui-time-select/latest'
+import type { DateTimeInputProps, DateTimeInputState } from './props'
+import { allowedProps } from './props'
+import { error } from '@instructure/console'
+
+/**
+---
+category: components
+---
+**/
+class DateTimeInput extends Component {
+ // extra verbose localized date and time
+ private static readonly DEFAULT_MESSAGE_FORMAT = 'LLLL'
+ static allowedProps = allowedProps
+ static defaultProps = {
+ layout: 'inline',
+ colSpacing: 'medium',
+ rowSpacing: 'small',
+ timeStep: 30,
+ showMessages: true,
+ messageFormat: DateTimeInput.DEFAULT_MESSAGE_FORMAT,
+ isRequired: false,
+ allowNonStepInput: false
+ } as const
+
+ declare context: React.ContextType
+ static contextType = ApplyLocaleContext
+
+ ref: Element | null = null // This is used by Tooltip for positioning
+
+ handleRef = (el: Element | null) => {
+ this.ref = el
+ }
+
+ constructor(props: DateTimeInputProps) {
+ super(props)
+ // State needs to be calculated because render could be called before
+ // componentDidMount()
+ this.state = this.recalculateState(props.value || props.defaultValue)
+ }
+
+ componentDidMount() {
+ // we'll need to recalculate the state because the context value is
+ // set at this point (and it might change locale & timezone)
+ const initState = this.recalculateState(
+ this.props.value || this.props.defaultValue
+ )
+ this.setState(initState)
+ this.props.reset?.(this.reset)
+ }
+
+ componentDidUpdate(prevProps: Readonly): void {
+ const valueChanged =
+ prevProps.value !== this.props.value ||
+ prevProps.defaultValue !== this.props.defaultValue
+ const isUpdated =
+ valueChanged ||
+ prevProps.locale !== this.props.locale ||
+ prevProps.timezone !== this.props.timezone ||
+ prevProps.dateFormat !== this.props.dateFormat ||
+ prevProps.messageFormat !== this.props.messageFormat ||
+ prevProps.invalidDateTimeMessage !== this.props.invalidDateTimeMessage
+
+ if (isUpdated) {
+ this.setState((_prevState: DateTimeInputState) => {
+ return {
+ ...this.recalculateState(this.props.value || this.props.defaultValue)
+ }
+ })
+ }
+ }
+
+ recalculateState(
+ dateStr?: string,
+ doNotChangeDate = false,
+ doNotChangeTime = false
+ ): DateTimeInputState {
+ let errorMsg: FormMessage | undefined
+ if (dateStr) {
+ const parsed = DateTime.parse(dateStr, this.locale(), this.timezone())
+ if (parsed.isValid()) {
+ if (doNotChangeTime && this.state.timeSelectValue) {
+ // There is a selected time, adjust the parsed date to its value
+ const timeParsed = DateTime.parse(
+ this.state.timeSelectValue,
+ this.locale(),
+ this.timezone()
+ )
+ parsed.hour(timeParsed.hour()).minute(timeParsed.minute())
+ }
+ if (doNotChangeDate && this.state.iso) {
+ parsed
+ .date(this.state.iso.date())
+ .month(this.state.iso.month())
+ .year(this.state.iso.year())
+ }
+ if (this.props.initialTimeForNewDate && !this.state?.timeSelectValue) {
+ const hour = Number(this.props.initialTimeForNewDate.slice(0, 2))
+ const minute = Number(this.props.initialTimeForNewDate.slice(3, 5))
+ if (isNaN(hour) || isNaN(minute)) {
+ error(
+ false,
+ `[DateTimeInput] initialTimeForNewDate prop is not in the correct format. Please use HH:MM format.`
+ )
+ } else if (hour < 0 || hour > 23 || minute > 59 || minute < 0) {
+ error(
+ false,
+ `[DateTimeInput] 0 <= hour < 24 and 0 <= minute < 60 for initialTimeForNewDate prop.`
+ )
+ } else {
+ parsed.hour(hour).minute(minute)
+ }
+ }
+ const newTimeSelectValue = parsed.toISOString()
+ if (this.isDisabledDate(parsed)) {
+ let text =
+ typeof this.props.disabledDateTimeMessage === 'function'
+ ? this.props.disabledDateTimeMessage(parsed.toISOString(true))
+ : this.props.disabledDateTimeMessage
+ if (!text) {
+ text =
+ typeof this.props.invalidDateTimeMessage === 'function'
+ ? this.props.invalidDateTimeMessage(parsed.toISOString(true))
+ : this.props.invalidDateTimeMessage
+ }
+ errorMsg = text ? { text, type: 'error' } : undefined
+ return {
+ iso: parsed.clone(),
+ dateInputText: this.formatDateInput(parsed.toDate()),
+ message: errorMsg,
+ timeSelectValue: newTimeSelectValue
+ }
+ }
+ return {
+ iso: parsed.clone(),
+ dateInputText: this.formatDateInput(parsed.toDate()),
+ message: {
+ type: 'success',
+ text: parsed.format(this.props.messageFormat)
+ },
+ timeSelectValue: newTimeSelectValue
+ }
+ }
+ }
+ // if there is no date string clear TimeSelect value
+ const clearTimeSelect: Partial = dateStr
+ ? {}
+ : {
+ timeSelectValue: '',
+ message: undefined
+ }
+ return {
+ iso: undefined,
+ dateInputText: dateStr ? dateStr : '',
+ ...clearTimeSelect
+ }
+ }
+
+ reset = () => this.setState(this.recalculateState())
+
+ locale(): string {
+ if (this.props.locale) {
+ return this.props.locale
+ } else if (this.context && this.context.locale) {
+ return this.context.locale
+ }
+ return Locale.browserLocale()
+ }
+
+ timezone() {
+ if (this.props.timezone) {
+ return this.props.timezone
+ } else if (this.context && this.context.timezone) {
+ return this.context.timezone
+ }
+ return DateTime.browserTimeZone()
+ }
+
+ private formatDateInput(date: Date): string {
+ const { dateFormat } = this.props
+ if (typeof dateFormat !== 'string' && dateFormat?.formatter) {
+ return dateFormat.formatter(date)
+ }
+ return date.toLocaleDateString(
+ typeof dateFormat === 'string' ? dateFormat : this.locale(),
+ {
+ timeZone: this.timezone(),
+ calendar: 'gregory',
+ numberingSystem: 'latn'
+ }
+ )
+ }
+
+ isDisabledDate(date: Moment) {
+ const disabledDates = this.props.disabledDates
+ if (!disabledDates) {
+ return false
+ }
+ if (Array.isArray(disabledDates)) {
+ for (const aDisabledDate of disabledDates) {
+ if (date.isSame(aDisabledDate, 'day')) {
+ return true
+ }
+ }
+ return false
+ }
+ return disabledDates(date.toISOString())
+ }
+
+ handleDateTextChange = (
+ _event: SyntheticEvent,
+ inputValue: string,
+ _utcDateString: string
+ ) => {
+ this.setState({ dateInputText: inputValue })
+ }
+
+ handleDateValidated = (
+ event: SyntheticEvent,
+ _inputValue: string,
+ utcDateString: string
+ ) => {
+ let newState: DateTimeInputState
+ if (
+ utcDateString &&
+ this.state.timeSelectValue &&
+ (!this.state.dateInputText || this.state.dateInputText === '')
+ ) {
+ const timeParsed = DateTime.parse(
+ this.state.timeSelectValue,
+ this.locale(),
+ this.timezone()
+ )
+ const dateParsed = DateTime.parse(
+ utcDateString,
+ this.locale(),
+ this.timezone()
+ )
+ const dateParsedAdjusted = dateParsed.set({
+ hour: timeParsed.hour(),
+ minute: timeParsed.minute()
+ })
+ newState = this.recalculateState(
+ dateParsedAdjusted.toISOString(),
+ false,
+ false
+ )
+ } else if (!utcDateString) {
+ // invalid date — pass raw text so error message is shown
+ newState = this.recalculateState(this.state.dateInputText, false, true)
+ } else {
+ newState = this.recalculateState(utcDateString, false, true)
+ }
+ this.changeStateIfNeeded(newState, event)
+ }
+
+ updateStateBasedOnTimeSelect = (
+ event: SyntheticEvent,
+ option: { value?: string; inputText: string }
+ ) => {
+ // this.state.iso is undefined if date is invalid or not set.
+ // in this case recalculate with the dateInput's text which will result in
+ // an empty valid date (if isRequired is false) or an invalid date.
+ const newValue = this.state.iso ? option.value : this.state.dateInputText
+ const newState = this.recalculateState(newValue, true, false)
+ this.changeStateIfNeeded(newState, event)
+ this.setState({ timeSelectValue: option.value })
+ }
+
+ changeStateIfNeeded = (newState: DateTimeInputState, e: SyntheticEvent) => {
+ const dateStr = newState.dateInputText
+ if (
+ (this.props.isRequired && !newState.iso) ||
+ (dateStr && dateStr.length > 0 && !newState.iso)
+ ) {
+ const text =
+ typeof this.props.invalidDateTimeMessage === 'function'
+ ? this.props.invalidDateTimeMessage(dateStr ? dateStr : '')
+ : this.props.invalidDateTimeMessage
+ // eslint-disable-next-line no-param-reassign
+ newState.message = { text: text, type: 'error' }
+ }
+ if (this.areDifferentDates(this.state.iso, newState.iso)) {
+ if (typeof this.props.onChange === 'function') {
+ const newDate = newState.iso?.toISOString()
+ // Timeout is needed here because users might change value in the
+ // onChange event lister, which might not execute properly
+ setTimeout(() => {
+ this.props.onChange?.(e, newDate)
+ }, 0)
+ }
+ }
+ this.setState(newState)
+ }
+
+ areDifferentDates = (d1?: Moment, d2?: Moment) => {
+ if (!d1 && !d2) {
+ return false
+ }
+ return !d1 || !d2 || !d1.isSame(d2)
+ }
+
+ handleBlur = (e: SyntheticEvent) => {
+ // when TABbing from the DateInput to TimeInput or visa-versa, the blur
+ // happens on the target before the relatedTarget gets focus.
+ // The timeout gives it a moment for that to happen
+ if (typeof this.props.onBlur === 'function') {
+ setTimeout(() => {
+ this.props.onBlur?.(e)
+ }, 0)
+ }
+ }
+
+ render() {
+ const {
+ description,
+ datePlaceholder,
+ timePlaceholder,
+ dateRenderLabel,
+ dateInputRef,
+ timeRenderLabel,
+ timeFormat,
+ timeStep,
+ timeInputRef,
+ locale,
+ timezone,
+ showMessages,
+ messages,
+ layout,
+ rowSpacing,
+ colSpacing,
+ isRequired,
+ interaction,
+ allowNonStepInput,
+ screenReaderLabels,
+ disabledDates,
+ withYearPicker
+ } = this.props
+
+ const allMessages = [
+ ...(showMessages && this.state.message ? [this.state.message] : []),
+ ...(messages || [])
+ ]
+
+ const hasError = allMessages.find(
+ (m) => m.type === 'newError' || m.type === 'error'
+ )
+ // if the component is in error state, create an empty error message to pass down to the subcomponents (DateInput and TimeInput) so they get a red outline and red required asterisk
+ const subComponentMessages: FormMessage[] = hasError
+ ? [{ type: 'error', text: '' }]
+ : []
+
+ return (
+
+ this.handleBlur(e)}
+ inputRef={dateInputRef}
+ placeholder={datePlaceholder}
+ isRequired={isRequired}
+ messages={subComponentMessages}
+ interaction={interaction}
+ locale={locale}
+ timezone={timezone}
+ disabledDates={disabledDates}
+ dateFormat={this.props.dateFormat}
+ />
+
+
+ )
+ }
+}
+
+export default DateTimeInput
+export { DateTimeInput }
diff --git a/packages/ui-date-time-input/src/DateTimeInput/v2/props.ts b/packages/ui-date-time-input/src/DateTimeInput/v2/props.ts
new file mode 100644
index 0000000000..128175513d
--- /dev/null
+++ b/packages/ui-date-time-input/src/DateTimeInput/v2/props.ts
@@ -0,0 +1,299 @@
+/*
+ * 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 { SyntheticEvent } from 'react'
+import type { FormMessage } from '@instructure/ui-form-field/latest'
+import type { InteractionType } from '@instructure/ui-react-utils'
+import type { Moment } from '@instructure/ui-i18n'
+import type { Renderable } from '@instructure/shared-types'
+
+type DateTimeInputProps = {
+ /**
+ * The label over the composite `DateTimeInput` component
+ **/
+ description: React.ReactNode
+ /**
+ * The label over the DateInput
+ **/
+ dateRenderLabel: Renderable
+ /**
+ * Accessible labels for the calendar icon button, month navigation buttons, and date picker dialog.
+ */
+ screenReaderLabels: {
+ calendarIcon: string
+ prevMonthButton: string
+ nextMonthButton: string
+ datePickerDialog?: string
+ }
+ /**
+ * HTML placeholder text to display when the date input has no value.
+ * This should be hint text, not a label replacement.
+ **/
+ datePlaceholder?: string
+
+ /**
+ * HTML placeholder text to display when the time input has no value.
+ * This should be hint text, not a label replacement.
+ **/
+ timePlaceholder?: string
+ /**
+ * Controls how the date is displayed in the input and parsed when the user types.
+ * When omitted, the locale determines the format.
+ * Pass a locale string (e.g. `"en-US"`) to use an alternate locale's format,
+ * or pass `{ parser, formatter }` functions for fully custom behaviour.
+ **/
+ dateFormat?:
+ | {
+ parser: (input: string) => Date | null
+ formatter: (date: Date) => string
+ }
+ | string
+ /**
+ * The label over the time input
+ **/
+ timeRenderLabel: Renderable
+ /**
+ * The number of minutes to increment by when generating the allowable time options.
+ */
+ timeStep?: 5 | 10 | 15 | 20 | 30 | 60
+ /**
+ * The format of the time shown in the `TimeSelect` when a time is selected.
+ * Valid formats are compatible with
+ * [Moment formats](https://momentjs.com/docs/#/displaying/format/),
+ * including localized formats.
+ *
+ * If omitted, defers to the underlying `TimeSelect`'s default.
+ **/
+ timeFormat?: string
+ /**
+ * A standard language identifier.
+ *
+ * See [Moment.js](https://momentjs.com/timezone/docs/#/using-timezones/parsing-in-zone/) for
+ * more details.
+ *
+ * This property can also be set via a context property and if both are set
+ * then the component property takes precedence over the context property.
+ *
+ * The web browser's locale will be used if no value is set via a component
+ * property or a context property.
+ **/
+ locale?: string
+ /**
+ * A timezone identifier in the format: *Area/Location*
+ *
+ * See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the list
+ * of possible options.
+ *
+ * This property can also be set via a context property and if both are set
+ * then the component property takes precedence over the context property.
+ *
+ * The web browser's timezone will be used if no value is set via a component
+ * property or a context property.
+ **/
+ timezone?: string
+ /**
+ * 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.
+ *
+ * Currently, times must be selected from a list, it can never be incorrect,
+ * Though `invalidDateTimeMessage()` will be called if the user selects a time without
+ * setting the date.
+ **/
+ invalidDateTimeMessage: string | ((rawDateValue: string) => string)
+ /**
+ * Toggles whether to show built-in messages (the date/time, or the
+ * `invalidDateTimeMessage`). Even when set to `false` the component will
+ * show user supplied messages by the `messages` prop.
+ * @default true
+ */
+ showMessages?: boolean
+ /**
+ * Extra message(s) to be displayed.
+ */
+ messages?: FormMessage[]
+ /**
+ * This format of the composite date-time when displayed in messages.
+ * Valid formats are defined in the
+ * [Moment docs](https://momentjs.com/docs/#/displaying/format/)
+ **/
+ messageFormat?: string
+ /**
+ * The layout of this component.
+ * Vertically stacked, horizontally arranged in 2 columns, or inline (default).
+ * See [FormFieldGroup](FormFieldGroup) for details.
+ **/
+ layout?: 'stacked' | 'columns' | 'inline'
+ /**
+ * Controls the spacing between the inputs when they are in a vertical layout.
+ **/
+ rowSpacing?: 'none' | 'small' | 'medium' | 'large'
+ /**
+ * Controls the spacing between the inputs when they are in a horizontal layout.
+ **/
+ colSpacing?: 'none' | 'small' | 'medium' | 'large'
+ /**
+ * An ISO 8601 formatted date string representing the current date-time
+ * (must be accompanied by an onChange prop).
+ **/
+ value?: string // TODO: controllable(I18nPropTypes.iso8601, 'onChange')
+ /**
+ * An ISO 8601 formatted date string to use if `value` isn't provided.
+ **/
+ defaultValue?: string
+ /**
+ * If set, years can be picked from a dropdown in the calendar.
+ * screenReaderLabel: string // e.g.: i18n("pick a year")
+ * onRequestYearChange?: (e: React.SyntheticEvent, requestedYear: number) => void
+ * startYear: number // e.g.: 2001, sets the start year of the selectable list
+ * endYear: number // e.g.: 2030, sets the end year of the selectable list
+ */
+ withYearPicker?: {
+ screenReaderLabel: string
+ onRequestYearChange?: (e: SyntheticEvent, requestedYear: number) => void
+ startYear: number
+ endYear: number
+ }
+ /**
+ * Specifies if the input is required (its passed down to the native DOM
+ * elements). If its `true` then an empty input will produce an error message
+ * (`invalidDateTimeMessage`)
+ */
+ isRequired?: boolean
+ /**
+ * Specifies if interaction with the input is enabled, disabled, or readonly.
+ * When "disabled", the input changes visibly to indicate that it cannot
+ * receive user interactions. When "readonly" the input still cannot receive
+ * user interactions but it keeps the same styles as if it were enabled.
+ */
+ interaction?: InteractionType
+ /**
+ * Called when the date-time value has changed.
+ * The passed in parameters are:
+ * - *event*: The triggering event (which may be from the underlying
+ * `DateInput` or `TimeSelect`)
+ * - *isoValue*: The new date value in ISO8601 format, undefined if its invalid
+ **/
+ onChange?: (event: SyntheticEvent, isoValue?: string) => void
+ /**
+ * The HTML `input` element where the date is entered.
+ **/
+ dateInputRef?: (el: HTMLInputElement | null) => void
+ /**
+ * The HTML `input` element where the time is entered.
+ **/
+ timeInputRef?: (el: HTMLInputElement | null) => void
+ /**
+ * onBlur event handler for when focus leaves DateTimeInput.
+ * Does not fire when focus moves between DateInput and TimeSelect within the
+ * component
+ */
+ onBlur?: (e: SyntheticEvent) => void
+ /*
+ * 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)
+ /**
+ * Error message shown to the user if they enter a date that is disabled.
+ * If not specified the component will show the `invalidDateTimeMessage`.
+ */
+ disabledDateTimeMessage?: string | ((rawDateValue: string) => string)
+ /**
+ * Whether to allow the user to enter non-step divisible values in the time
+ * input field. Note that even if this is set to false one can enter non-step
+ * divisible values programmatically. The user will need to enter the value
+ * exactly (except for lower/uppercase) as specified by the `timeFormat` prop
+ * for it to be accepted.
+ * Default is `undefined` which equals to `false`
+ */
+ allowNonStepInput?: boolean
+ /**
+ * The default time to be prefilled if a day is selected. The time input has to be empty for this to be applied.
+ * An error is thrown if the time format is not HH:MM.
+ */
+ initialTimeForNewDate?: string
+ /**
+ * Used for getting the internal reset function of DateTimeInput. If that
+ * function is called, the component will reset to its default inner state.
+ * The callback function will be called in componentDidMount
+ * NOTE: this won't call onChange, so you have to reset the value manually when calling reset
+ */
+ reset?: (reset: () => void) => void
+}
+
+type DateTimeInputState = {
+ // the time and date currently selected
+ iso?: Moment
+ // The value currently displayed in the dateInput component.
+ // Just the date part is visible
+ dateInputText: string
+ // The value currently displayed in the timeSelect component as ISO datetime
+ timeSelectValue?: string
+ // The message (success/error) shown below the component
+ message?: FormMessage
+}
+
+type PropKeys = keyof DateTimeInputProps
+type AllowedPropKeys = Readonly>
+
+const allowedProps: AllowedPropKeys = [
+ 'description',
+ 'dateRenderLabel',
+ 'screenReaderLabels',
+ 'datePlaceholder',
+ 'timePlaceholder',
+ 'dateFormat',
+ 'interaction',
+ 'timeRenderLabel',
+ 'timeStep',
+ 'timeFormat',
+ 'locale',
+ 'timezone',
+ 'invalidDateTimeMessage',
+ 'showMessages',
+ 'messages',
+ 'messageFormat',
+ 'layout',
+ 'rowSpacing',
+ 'colSpacing',
+ 'value',
+ 'defaultValue',
+ 'isRequired',
+ 'onChange',
+ 'dateInputRef',
+ 'timeInputRef',
+ 'onBlur',
+ 'disabledDates',
+ 'disabledDateTimeMessage',
+ 'allowNonStepInput',
+ 'reset',
+ 'withYearPicker'
+]
+
+export type { DateTimeInputProps, DateTimeInputState }
+export { allowedProps }
diff --git a/packages/ui-date-time-input/src/exports/b.ts b/packages/ui-date-time-input/src/exports/b.ts
new file mode 100644
index 0000000000..c647c17cd0
--- /dev/null
+++ b/packages/ui-date-time-input/src/exports/b.ts
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+export { DateTimeInput } from '../DateTimeInput/v2'
diff --git a/packages/ui-select/src/Select/v2/index.tsx b/packages/ui-select/src/Select/v2/index.tsx
index 78785cfb20..59e087ca8d 100644
--- a/packages/ui-select/src/Select/v2/index.tsx
+++ b/packages/ui-select/src/Select/v2/index.tsx
@@ -44,8 +44,7 @@ import { TextInput } from '@instructure/ui-text-input/latest'
import { Options } from '@instructure/ui-options/latest'
import {
ChevronDownInstUIIcon,
- ChevronUpInstUIIcon,
- CheckInstUIIcon
+ ChevronUpInstUIIcon
} from '@instructure/ui-icons'
import type { ViewProps } from '@instructure/ui-view/latest'
@@ -422,13 +421,6 @@ class Select extends Component {
: (renderOptionLabel as React.ReactNode)
}
- const { isOptionContentAppliedToInput, size = 'medium' } = this.props
- const iconSize = selectSizeToIconSize[size]
- const checkIcon =
- isSelected && !isOptionContentAppliedToInput ? (
-
- ) : null
-
let optionProps: Partial = {
// passthrough props
...omitProps(option.props, [
@@ -439,7 +431,7 @@ class Select extends Component {
...getOptionProps({ id }),
// Options.Item props
renderBeforeLabel: getRenderOptionLabel(renderBeforeLabel),
- renderAfterLabel: checkIcon ?? getRenderOptionLabel(renderAfterLabel)
+ renderAfterLabel: getRenderOptionLabel(renderAfterLabel)
}
// should option be treated as highlighted or selected
if (isSelected && isHighlighted) {
diff --git a/regression-test/src/app/datetimeinput/page.tsx b/regression-test/src/app/datetimeinput/page.tsx
index 7bb7369d0c..4e79167175 100644
--- a/regression-test/src/app/datetimeinput/page.tsx
+++ b/regression-test/src/app/datetimeinput/page.tsx
@@ -46,8 +46,11 @@ export default function DateTimeInputPage() {
dateRenderLabel="Date"
timeRenderLabel="Time"
invalidDateTimeMessage="Invalid date!"
- prevMonthLabel="Previous month"
- nextMonthLabel="Next month"
+ screenReaderLabels={{
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ }}
defaultValue="2018-01-18T13:30"
layout="columns"
/>
@@ -59,8 +62,11 @@ export default function DateTimeInputPage() {
datePlaceholder="Choose"
dateRenderLabel="Date"
timeRenderLabel="Time"
- prevMonthLabel="Previous month"
- nextMonthLabel="Next month"
+ screenReaderLabels={{
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ }}
onChange={(_e: any, isoDate?: string) => {
let messages: any[] = []
if (!isoDate) {
@@ -89,8 +95,11 @@ export default function DateTimeInputPage() {
datePlaceholder="Choose a date"
dateRenderLabel="Date"
timeRenderLabel="Time"
- prevMonthLabel="Previous month"
- nextMonthLabel="Next month"
+ screenReaderLabels={{
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ }}
invalidDateTimeMessage={(dvalue: string) => `'${dvalue} is not valid.`}
layout="columns"
defaultValue="2018-01-18T13:30"
diff --git a/regression-test/src/app/form-errors/page.tsx b/regression-test/src/app/form-errors/page.tsx
index e666440c46..e175b1ba55 100644
--- a/regression-test/src/app/form-errors/page.tsx
+++ b/regression-test/src/app/form-errors/page.tsx
@@ -102,8 +102,11 @@ export default function FormErrorsPage() {
dateRenderLabel="Date"
timeRenderLabel="Time"
invalidDateTimeMessage="Invalid date!"
- prevMonthLabel="Previous month"
- nextMonthLabel="Next month"
+ screenReaderLabels={{
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ }}
defaultValue="2018-01-18T13:30"
layout="columns"
isRequired
@@ -116,8 +119,11 @@ export default function FormErrorsPage() {
dateRenderLabel="Date"
timeRenderLabel="Time"
invalidDateTimeMessage="Invalid date!"
- prevMonthLabel="Previous month"
- nextMonthLabel="Next month"
+ screenReaderLabels={{
+ calendarIcon: 'Open calendar',
+ prevMonthButton: 'Previous month',
+ nextMonthButton: 'Next month'
+ }}
defaultValue="2018-01-18T13:30"
layout="stacked"
isRequired