From 67a968ac00f45874d2bbbd7e0c66a5e43fc32da7 Mon Sep 17 00:00:00 2001 From: Tamas Kovacs Date: Fri, 24 Apr 2026 10:47:11 +0200 Subject: [PATCH] feat(ui-select,ui-date-time-input): rework DateTimeInput and replace DateInput v1 with DateInput v2 in DateTimeInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: prevMonthLabel prop removed (use screenReaderLabels.prevMonthButton instead) nextMonthLabel prop removed (use screenReaderLabels.nextMonthButton instead) renderWeekdayLabels prop removed dateFormat type changed: string → string { parser: (input: string) => Date null, formatter: (date: Date) => string } screenReaderLabels is a new required prop dateFormat default changed: Moment's 'LL' (long month name) → locale's default date format INSTUI-4791 --- cypress/component/DateTimeInput.cy.tsx | 112 ++-- docs/guides/upgrade-guide.md | 48 ++ packages/ui-date-time-input/package.json | 20 +- .../__tests__/DateTimeInput.test.tsx | 535 +++++++++++++++--- .../src/DateTimeInput/v2/README.md | 261 +++++++++ .../src/DateTimeInput/v2/index.tsx | 439 ++++++++++++++ .../src/DateTimeInput/v2/props.ts | 299 ++++++++++ packages/ui-date-time-input/src/exports/b.ts | 25 + packages/ui-select/src/Select/v2/index.tsx | 12 +- .../src/app/datetimeinput/page.tsx | 21 +- regression-test/src/app/form-errors/page.tsx | 14 +- 11 files changed, 1637 insertions(+), 149 deletions(-) create mode 100644 packages/ui-date-time-input/src/DateTimeInput/v2/README.md create mode 100644 packages/ui-date-time-input/src/DateTimeInput/v2/index.tsx create mode 100644 packages/ui-date-time-input/src/DateTimeInput/v2/props.ts create mode 100644 packages/ui-date-time-input/src/exports/b.ts 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 ( +
+
+ + You entered: +
+ {text} +
+
+
+ Pick a date and time + } + datePlaceholder="Choose" + dateRenderLabel="Date" + timeRenderLabel="Time" + screenReaderLabels={{ calendarIcon: 'Open calendar', prevMonthButton: 'Previous month', nextMonthButton: 'Next month' }} + onChange={onChange} + layout="stacked" + value={value} + invalidDateTimeMessage="Invalid date!" + messages={messages} + allowNonStepInput={true} + isRequired + /> +
+
+ ) + } + + 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 ( +
+ + 'Invalid date: ' + rawDateValue + } + disabledDateTimeMessage={(rawDateValue) => + 'Disabled date: ' + rawDateValue + } + screenReaderLabels={{ calendarIcon: 'Open calendar', prevMonthButton: 'Previous month', nextMonthButton: 'Next month' }} + defaultValue="2022-04-08T13:30" + layout="columns" + disabledDates={getDisabledDates} + locale={locale} + timezone={timezone} + /> +
+ ) + } + + 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 ( +
+ setDate(newDate)} + reset={(reset) => (resetFn.current = reset)} + /> + + {date} +
+ ) + } + 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