diff --git a/cypress/component/TimeSelect.cy.tsx b/cypress/component/TimeSelect.cy.tsx new file mode 100644 index 0000000000..dc2b28a1ff --- /dev/null +++ b/cypress/component/TimeSelect.cy.tsx @@ -0,0 +1,342 @@ +/* + * 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 React from 'react' +import moment from 'moment-timezone' +import 'cypress-real-events' + +import '../support/component' +import { TimeSelect } from '../../packages/ui' +import { DateTime } from '../../packages/ui-i18n' + +describe('', () => { + it('should render an input and list', async () => { + cy.mount() + + cy.get('input[id^="Select_"]').as('input') + cy.contains('ul[id^="Selectable_"]').should('not.exist') + + cy.get('@input').realClick() + + cy.get('ul[id^="Selectable_"]').should('exist') + }) + + it('should fire onChange when selected option changes', async () => { + const onChange = cy.spy() + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + cy.contains('ul[class$="-options__list"]').should('not.exist') + + cy.get('@input').click() + + cy.get('ul[class$="-options__list"]').should('be.visible') + cy.get('li[class$="-optionItem"]').eq(0).as('option1') + cy.get('@option1').should('have.text', '00:00') + + cy.get('@option1').click() + + cy.get('@input').should('have.value', '00:00') + cy.wrap(onChange) + .should('have.been.called') + .then((spy) => { + expect(spy.lastCall.args[1]).to.have.property('value') + expect(spy.lastCall.args[1]).to.have.property('inputText', '00:00') + }) + }) + + it('should behave uncontrolled', async () => { + const onChange = cy.spy() + cy.mount() + + cy.get('input[id^="Select_"]').as('input') + cy.get('@input').should('have.value', '') + + cy.get('@input').click() + + cy.get('li[class$="-optionItem"]').eq(0).as('option1') + + cy.get('@option1').click() + + cy.get('@input').should('have.value', '00:00') + }) + + it('should behave controlled', async () => { + const onChange = cy.spy() + let selectedValue = '' + const initialTestValue = moment + .tz('1986-05-17T05:00:00.000Z', moment.ISO_8601, 'en', 'US/Eastern') + .toISOString() + + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + cy.get('@input').should('have.value', '01:00') + + cy.get('@input').click() + + cy.get('li[class$="-optionItem"]').eq(4).as('option5') + cy.get('@option5') + .invoke('text') + .then((text) => { + selectedValue = text + }) + + cy.get('@option5').click() + + cy.get('@input').should('have.value', '01:00') // not changed because it's hardcoded + cy.wrap(onChange) + .should('have.been.called') + .then((spy) => { + const args = spy.lastCall.args[1] + + expect(args.value).to.not.equal(initialTestValue) + + // update component with the new value + cy.mount( + + ) + cy.get('@input').should('have.value', selectedValue) + }) + }) + + it('Pressing ESC should reset the value in controlled mode', async () => { + const onChange = cy.spy() + const onKeyDown = cy.spy() + const handleInputChange = cy.spy() + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + + cy.get('@input').realClick().realType('7:45 PM') + cy.get('@input').realPress('Escape') + + cy.wrap(onChange).should('not.have.been.called') + cy.wrap(onKeyDown).should('have.been.called') + cy.wrap(handleInputChange).should('have.been.called') + cy.get('@input').should('have.value', '') + }) + + it('value should not be changeable via user input in controlled mode', async () => { + const dateTime = DateTime.parse('2017-05-01T17:30Z', 'en-US', 'GMT') + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + + cy.get('@input').clear().realType('1:45 PM') + cy.get('@input').realPress('Enter') + cy.get('@input').blur() + + cy.get('@input').should('have.value', '1:30 PM') + }) + + it('should keep selection when value changes', async () => { + const onChange = cy.spy() + const locale = 'en-US' + const timezone = 'US/Eastern' + const dateTime = DateTime.parse('2017-05-01T17:30Z', locale, timezone) + + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + cy.get('@input').should('have.value', '13:30') + + const newDateStr = '2022-03-29T19:00Z' + const newDateTime = DateTime.parse(newDateStr, locale, timezone) + + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input2') + cy.get('@input2').should('have.value', '15:00') + }) + + it('should accept values that are not divisible by step', async () => { + const onChange = cy.spy() + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + + // this expectation is needed so TimeSelect generates some default options + cy.get('@input').should('have.attr', 'value', '') + + const value = moment.tz( + '1986-05-17T05:02:00.000Z', + moment.ISO_8601, + 'en', + 'US/Eastern' + ) + + cy.mount( + + ) + cy.get('@input').should('have.attr', 'value', '01:02') + }) + + it('should use the specified step value', async () => { + const value = moment.tz( + '1986-05-17T18:00:00.000Z', + moment.ISO_8601, + 'en', + 'US/Eastern' + ) + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + + cy.get('@input').click() + + cy.get('@input').should('have.value', '14:00') + + cy.get('li[class$="-optionItem"]').eq(0).as('option1') + cy.get('li[class$="-optionItem"]').eq(1).as('option2') + cy.get('@option1').should('have.text', '00:00') + cy.get('@option2').should('have.text', '00:15') + }) + + it('should not allow non-step value when allowNonStepInput=false', async () => { + const onChange = cy.spy() + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + + cy.get('@input').realClick().realType('7:34 PM') + cy.get('@input').realPress('Enter') // should not accept the value and send onChange event + cy.get('@input').realPress('Escape') // should reset the value + + cy.wrap(onChange).should('not.have.been.called') + cy.get('@input').should('have.value', '') + }) + + it('should allow non-step value when allowNonStepInput=true', async () => { + const onChange = cy.spy() + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + + cy.get('@input').realClick().realType('7:34 PM') + cy.get('@input').blur() // sends onChange event + + cy.wrap(onChange) + .should('have.been.called') + .then((spy) => { + expect(spy.lastCall.args[1]).to.have.property('value') + cy.get('@input').should('have.attr', 'value', '7:34 PM') + }) + }) + + it('should round down seconds when applicable', async () => { + const onChange = cy.spy() + cy.mount( + + ) + cy.get('input[id^="Select_"]').as('input') + cy.get('@input').should('have.value', '') + + cy.get('@input').realClick().realType('04:45:55 AM') + cy.get('@input').blur() // sends onChange event + + cy.get('@input').should('have.value', '4:45:00 AM') + }) +}) diff --git a/package-lock.json b/package-lock.json index 5f73fb161f..58ec03ea1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46252,20 +46252,79 @@ "@instructure/ui-prop-types": "10.2.1", "@instructure/ui-react-utils": "10.2.1", "@instructure/ui-select": "10.2.1", - "@instructure/ui-test-locator": "10.2.1", "@instructure/ui-testable": "10.2.1", "@instructure/ui-utils": "10.2.1", "prop-types": "^15.8.1" }, "devDependencies": { + "@instructure/ui-axe-check": "10.2.1", "@instructure/ui-babel-preset": "10.2.1", "@instructure/ui-test-utils": "10.2.1", - "moment-timezone": "^0.5.45" + "@testing-library/jest-dom": "^6.4.6", + "@testing-library/react": "^15.0.7", + "@testing-library/user-event": "^14.5.2", + "moment-timezone": "^0.5.45", + "vitest": "^2.0.2" }, "peerDependencies": { "react": ">=16.8 <=18" } }, + "packages/ui-time-select/node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "packages/ui-time-select/node_modules/@testing-library/react": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^10.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "packages/ui-time-select/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "packages/ui-toggle-details": { "name": "@instructure/ui-toggle-details", "version": "10.2.1", diff --git a/packages/ui-time-select/package.json b/packages/ui-time-select/package.json index ec45fdc114..48d40c319f 100644 --- a/packages/ui-time-select/package.json +++ b/packages/ui-time-select/package.json @@ -31,15 +31,19 @@ "@instructure/ui-prop-types": "10.2.1", "@instructure/ui-react-utils": "10.2.1", "@instructure/ui-select": "10.2.1", - "@instructure/ui-test-locator": "10.2.1", "@instructure/ui-testable": "10.2.1", "@instructure/ui-utils": "10.2.1", "prop-types": "^15.8.1" }, "devDependencies": { + "@instructure/ui-axe-check": "10.2.1", "@instructure/ui-babel-preset": "10.2.1", "@instructure/ui-test-utils": "10.2.1", - "moment-timezone": "^0.5.45" + "moment-timezone": "^0.5.45", + "@testing-library/jest-dom": "^6.4.6", + "@testing-library/react": "^15.0.7", + "@testing-library/user-event": "^14.5.2", + "vitest": "^2.0.2" }, "peerDependencies": { "react": ">=16.8 <=18" diff --git a/packages/ui-time-select/src/TimeSelect/TimeSelectLocator.ts b/packages/ui-time-select/src/TimeSelect/TimeSelectLocator.ts deleted file mode 100644 index 34dca8f235..0000000000 --- a/packages/ui-time-select/src/TimeSelect/TimeSelectLocator.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 - present Instructure, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -import { locator } from '@instructure/ui-test-locator' - -/* eslint-disable no-restricted-imports */ -// @ts-ignore: Cannot find module -import { SelectLocator } from '@instructure/ui-select/es/Select/SelectLocator' -/* eslint-enable no-restricted-imports */ - -import { TimeSelect } from './index' - -// @ts-expect-error ts-migrate(2339) FIXME: Property 'selector' does not exist on type 'typeof... Remove this comment to see the full error message -export const TimeSelectLocator = locator(TimeSelect.selector, { - findInput: SelectLocator.findInput as (...args: any) => Promise, // TODO these dont work because TS doesnt find its type declarations - findOptionsList: SelectLocator.findOptionsList as ( - ...args: any - ) => Promise -}) diff --git a/packages/ui-time-select/src/TimeSelect/__new-tests__/TimeSelect.test.tsx b/packages/ui-time-select/src/TimeSelect/__new-tests__/TimeSelect.test.tsx new file mode 100644 index 0000000000..bc20edf059 --- /dev/null +++ b/packages/ui-time-select/src/TimeSelect/__new-tests__/TimeSelect.test.tsx @@ -0,0 +1,341 @@ +/* + * 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 React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import moment from 'moment-timezone' +import '@testing-library/jest-dom' + +// eslint-disable-next-line no-restricted-imports +import { generateA11yTests } from '@instructure/ui-scripts/lib/test/generateA11yTests' +import { runAxeCheck } from '@instructure/ui-axe-check' +import { ApplyLocale } from '@instructure/ui-i18n' + +import { TimeSelect } from '../index' +import TimeSelectExamples from '../__examples__/TimeSelect.examples' + +describe('', () => { + let consoleWarningMock: ReturnType + let consoleErrorMock: ReturnType + + beforeEach(() => { + // Mocking console to prevent test output pollution + consoleWarningMock = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}) as any + consoleErrorMock = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) as any + }) + + afterEach(() => { + consoleWarningMock.mockRestore() + consoleErrorMock.mockRestore() + }) + + it('should fire onFocus when input gains focus', async () => { + const onFocus = vi.fn() + render() + + const input = screen.getByRole('combobox') + + input.focus() + + await waitFor(() => { + expect(onFocus).toHaveBeenCalled() + }) + }) + + it('should render a default value', async () => { + const defaultValue = moment.tz( + '1986-05-17T18:00:00.000Z', + moment.ISO_8601, + 'en', + 'US/Eastern' + ) + + const onChange = vi.fn() + + render( + + ) + const input = screen.getByRole('combobox') + + expect(input).toHaveValue('2:00 PM') + }) + + it('should display value when both defaultValue and value are set', async () => { + const value = moment.tz( + '1986-05-17T18:00:00.000Z', + moment.ISO_8601, + 'en', + 'US/Eastern' + ) + const defaultValue = moment.tz( + '1986-05-25T19:00:00.000Z', + moment.ISO_8601, + 'en', + 'US/Eastern' + ) + render( + + ) + const input = screen.getByRole('combobox') + + expect(input).toHaveValue(value.format('LT')) + }) + + it('should default to the first option if defaultToFirstOption is true', async () => { + render() + const input = screen.getByRole('combobox') + + expect(input).toHaveValue('12:00 AM') + }) + + it('should use the specified timezone', async () => { + const value = moment.tz( + '2024-01-11T13:00:00.000Z', + moment.ISO_8601, + 'fr', + 'UTC' + ) + + render( + + ) + const input = screen.getByRole('combobox') + + expect(input).toHaveValue('16:00') + }) + + it('should use the specified locale', async () => { + const value = moment.tz( + '2024-01-11T13:00:00.000Z', + moment.ISO_8601, + 'fr', // 24-hour clock + 'UTC' + ) + render( + + ) + const input = screen.getByRole('combobox') + + expect(input).toHaveValue('1:00 PM') + }) + + it('should handle winter and summer time based on timezone', async () => { + const valueSummer = moment.tz( + '2024-07-11T13:00:00.000Z', + moment.ISO_8601, + 'en', + 'UTC' // no time offset + ) + + const valueWinter = moment.tz( + '2024-01-11T13:00:00.000Z', + moment.ISO_8601, + 'en', + 'UTC' // no time offset + ) + + const { rerender } = render( + + ) + const input = screen.getByRole('combobox') + + expect(input).toHaveValue('2:00 PM') + + rerender( + + ) + const inputUpdated = screen.getByRole('combobox') + + expect(inputUpdated).toHaveValue('1:00 PM') + }) + + it('should read locale and timezone from context', async () => { + const value = moment.tz( + '2017-05-01T17:30Z', + moment.ISO_8601, + 'en', // 12-hour clock format + 'UTC' // no time offset + ) + render( + + + + ) + const input = screen.getByRole('combobox') + + expect(input).toHaveValue('20:30') + }) + + it('adding event listeners does not break functionality', async () => { + const onChange = vi.fn() + const onKeyDown = vi.fn() + const handleInputChange = vi.fn() + render( + + ) + const input = screen.getByRole('combobox') + + await userEvent.type(input, '7:45 PM') + fireEvent.blur(input) // sends onChange event + + await waitFor(() => { + expect(onChange).toHaveBeenCalled() + expect(onKeyDown).toHaveBeenCalled() + expect(handleInputChange).toHaveBeenCalled() + expect(input).toHaveValue('7:45 PM') + }) + }) + + describe('input', () => { + it('should render with a custom id if given', async () => { + render() + + const input = screen.getByRole('combobox') + + expect(input).toHaveAttribute('id', 'timeSelect') + }) + + it('should render readonly when interaction="readonly"', async () => { + render() + const input = screen.getByRole('combobox') + + expect(input).toHaveAttribute('readonly') + expect(input).not.toHaveAttribute('disabled') + }) + + it('should render disabled when interaction="disabled"', async () => { + render() + const input = screen.getByRole('combobox') + + expect(input).toHaveAttribute('disabled') + expect(input).not.toHaveAttribute('readonly') + }) + + it('should render required when isRequired is true', async () => { + render() + const input = screen.getByRole('combobox') + + expect(input).toHaveAttribute('required') + }) + + it('should allow custom props to pass through', async () => { + render() + const input = screen.getByRole('combobox') + + expect(input).toHaveAttribute('data-custom-attr', 'true') + }) + + it('should provide a ref to the input element', async () => { + const inputRef = vi.fn() + + render() + const input = screen.getByRole('combobox') + + expect(inputRef).toHaveBeenCalledWith(input) + }) + }) + + describe('list', () => { + it('should provide a ref to the list element', async () => { + const listRef = vi.fn() + render() + + const input = screen.getByRole('combobox') + + await userEvent.click(input) + + await waitFor(() => { + const listbox = screen.getByRole('listbox') + + expect(listRef).toHaveBeenCalledWith(listbox) + }) + }) + }) + + describe('with generated examples', () => { + const generatedComponents = generateA11yTests( + TimeSelect, + TimeSelectExamples + ) + + it.each(generatedComponents)( + 'should be accessible with example: $description', + async ({ content }) => { + const { container } = render(content) + const axeCheck = await runAxeCheck(container) + expect(axeCheck).toBe(true) + } + ) + }) +}) diff --git a/packages/ui-time-select/src/TimeSelect/__tests__/TimeSelect.test.tsx b/packages/ui-time-select/src/TimeSelect/__tests__/TimeSelect.test.tsx deleted file mode 100644 index 264f2c89c0..0000000000 --- a/packages/ui-time-select/src/TimeSelect/__tests__/TimeSelect.test.tsx +++ /dev/null @@ -1,573 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 - present Instructure, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import React from 'react' -import { - expect, - mount, - generateA11yTests, - stub, - wait -} from '@instructure/ui-test-utils' -import type { SinonStub } from '@instructure/ui-test-utils' -import moment from 'moment-timezone' -import { TimeSelect } from '../index' -import { TimeSelectLocator } from '../TimeSelectLocator' -import TimeSelectExamples from '../__examples__/TimeSelect.examples' -import { DateTime, ApplyLocale } from '@instructure/ui-i18n' - -describe('', async () => { - const lastCall = (spy: SinonStub) => spy.lastCall.args - - it('should render an input and list', async () => { - await mount() - const select = await TimeSelectLocator.find() - const input = await select.findInput() - let list = await select.findOptionsList({ expectEmpty: true }) - - expect(input).to.exist() - expect(list).to.not.exist() - - await input.click() - - list = await select.findOptionsList() - expect(list).to.exist() - }) - - it('should fire onChange when selected option changes', async () => { - const onChange = stub() - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - await input.click() - const list = await select.findOptionsList() - const options = await list.findAll('[role="option"]') - - await options[1].click() - expect(lastCall(onChange)[1].value).to.exist() - }) - - it('should fire onFocus when input gains focus', async () => { - const onFocus = stub() - await mount() - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - await input.focus() - - await wait(() => { - expect(onFocus).to.have.been.called() - }) - }) - - it('should behave uncontrolled', async () => { - const onChange = stub() - await mount() - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('value')).to.equal('') - - await input.click() - const list = await select.findOptionsList() - const options = await list.findAll('[role="option"]') - - await options[0].click() - expect(input.getAttribute('value')).to.equal('12:00 AM') - }) - - it('should behave controlled', async () => { - const onChange = stub() - const value = moment.tz( - '1986-05-17T05:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Eastern' - ) - - const subject = await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('value')).to.equal(value.format('LT')) - - await input.click() - const list = await select.findOptionsList() - const options = await list.findAll('[role="option"]') - - await options[3].click() - await subject.setProps({ value: lastCall(onChange)[1].value }) - expect(input.getAttribute('value')).to.equal(options[3].getTextContent()) - }) - - it('Pressing ESC should reset the value in controlled mode', async () => { - const onChange = stub() - const onKeyDown = stub() - const handleInputChange = stub() - const dateTime = DateTime.parse('2017-05-01T17:30Z', 'en-US', 'GMT') - const subject = await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - await subject.setProps({ value: '' }) - await input.typeIn('7:45 PM') - await input.keyUp('Esc') // should reset the value - expect(onChange).to.have.been.not.called() - expect(onKeyDown).to.have.been.called() - expect(handleInputChange).to.have.been.called() - expect(input.getAttribute('value')).to.equal('') - }) - - it('value should not be changeable via user input in controlled mode', async () => { - const dateTime = DateTime.parse('2017-05-01T17:30Z', 'en-US', 'GMT') - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - await input.change({ target: { value: '' } }) - await input.typeIn('1:45 PM') - await input.keyDown('Enter') - await input.focusOut() - expect(input.getAttribute('value')).to.equal('1:30 PM') - }) - - it('should keep selection when value changes', async () => { - const onChange = stub() - const locale = 'en-US' - const timezone = 'US/Eastern' - const dateTime = DateTime.parse('2017-05-01T17:30Z', locale, timezone) - - const subject = await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('value')).to.equal(dateTime.format('LT')) - - const newDateStr = '2022-03-29T19:00Z' - const newDateTime = DateTime.parse(newDateStr, locale, timezone) - await subject.setProps({ value: newDateTime }) - expect(input.getAttribute('value')).to.equal(newDateTime.format('LT')) - }) - - it('should accept values that are not divisible by step', async () => { - const onChange = stub() - const subject = await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - // this expect() is needed so TimeSelect generates some default options - expect(input.getAttribute('value')).to.equal('') - - const value = moment.tz( - '1986-05-17T05:02:00.000Z', - moment.ISO_8601, - 'en', - 'US/Eastern' - ) - await subject.setProps({ value: value.toISOString() }) - expect(input.getAttribute('value')).to.equal(value.format('LT')) - }) - - it('should render a default value', async () => { - const defaultValue = moment.tz( - '1986-05-17T18:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Eastern' - ) - - const onChange = stub() - - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('value')).to.equal('2:00 PM') - }) - - it('should display value when both defaultValue and value are set', async () => { - const value = moment.tz( - '1986-05-17T18:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Eastern' - ) - const defaultValue = moment.tz( - '1986-05-25T19:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Eastern' - ) - await mount( - - ) - const timeInput = await TimeSelectLocator.find() - const input = await timeInput.findInput() - - expect(input.getDOMNode().value).to.equal(value.format('LT')) - }) - - it('should default to the first option if defaultToFirstOption is true', async () => { - await mount() - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('value')).to.equal('12:00 AM') - }) - - it('should use the specified timezone', async () => { - const value = moment.tz( - '1986-05-17T18:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Central' - ) - const oneHourBackValue = moment.tz( - '1986-05-17T18:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Mountain' - ) - const oneHourForwardBackValue = moment.tz( - '1986-05-17T18:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Eastern' - ) - await mount( - - ) - const timeInput = await TimeSelectLocator.find() - const input = await timeInput.findInput() - - expect(input.getAttribute('value')).to.not.equal( - oneHourBackValue.format('LT') - ) - expect(input.getAttribute('value')).to.not.equal( - oneHourForwardBackValue.format('LT') - ) - }) - - it('should use the specified locale', async () => { - const value = moment.tz( - '1986-05-17T18:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Eastern' - ) - await mount( - - ) - const timeInput = await TimeSelectLocator.find() - const input = await timeInput.findInput() - - expect(input.getAttribute('value')).to.not.equal(value.format('LT')) - expect(input.getAttribute('value')).to.equal( - value.locale('fr').format('LT') - ) - }) - - it('should use the specified step value', async () => { - const value = moment.tz( - '1986-05-17T18:00:00.000Z', - moment.ISO_8601, - 'en', - 'US/Eastern' - ) - await mount( - - ) - const timeInput = await TimeSelectLocator.find() - const input = await timeInput.findInput() - - await input.click() - - await wait(async () => { - const list = await timeInput.findOptionsList() - const options = await list.findAll('[role="option"]') - - expect(input.getAttribute('value')).to.equal(value.format('LT')) - - expect(options[0].getTextContent()).to.equal( - value.hour(0).minute(0).format('LT') - ) - expect(options[1].getTextContent()).to.equal( - value.hour(0).minute(15).format('LT') - ) - }) - }) - - it('should read locale and timezone from context', async () => { - const dateTime = DateTime.parse('2017-05-01T17:30Z', 'en-US', 'GMT') - await mount( - // Africa/Nairobi is GMT +3 - - - - ) - const timeInput = await TimeSelectLocator.find() - const input = await timeInput.findInput() - expect(input.getAttribute('value')).to.equal('20:30') - }) - - it('should not allow non-step value when allowNonStepInput=false', async () => { - const onChange = stub() - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - await input.typeIn('7:34 PM') - await input.keyDown('Enter') // should not send onChange event - await input.keyUp('esc') // should reset the value - expect(onChange).to.have.not.been.called() - expect(input.getAttribute('value')).to.equal('') - }) - - it('should allow non-step value when allowNonStepInput=true', async () => { - const onChange = stub() - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - await input.typeIn('7:34 PM') - await input.focusOut() // sends onChange event - expect(onChange).to.have.been.called() - expect(lastCall(onChange)[1].value).to.exist() - expect(input.getAttribute('value')).to.equal('7:34 PM') - }) - - it('should round down seconds when applicable', async () => { - const onChange = stub() - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - await input.change({ target: { value: '' } }) - await input.typeIn('4:45:55 AM') - await input.focusOut() // sends onChange event - expect(input.getAttribute('value')).to.equal('4:45:00 AM') - }) - - it('adding event listeners does not break functionality', async () => { - const onChange = stub() - const onKeyDown = stub() - const handleInputChange = stub() - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - await input.typeIn('7:45 PM') - await input.focusOut() // sends onChange event - expect(onChange).to.have.been.called() - expect(onKeyDown).to.have.been.called() - expect(handleInputChange).to.have.been.called() - expect(input.getAttribute('value')).to.equal('7:45 PM') - }) - - describe('input', async () => { - it('should render with a custom id if given', async () => { - await mount() - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('id')).to.equal('timeSelect') - }) - - it('should render readonly when interaction="readonly"', async () => { - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('readonly')).to.exist() - expect(input.getAttribute('disabled')).to.not.exist() - }) - - it('should render disabled when interaction="disabled"', async () => { - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('disabled')).to.exist() - expect(input.getAttribute('readonly')).to.not.exist() - }) - - it('should render required when isRequired is true', async () => { - await mount() - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('required')).to.exist() - }) - - it('should allow custom props to pass through', async () => { - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(input.getAttribute('data-custom-attr')).to.equal('true') - }) - - it('should provide a ref to the input element', async () => { - const inputRef = stub() - - await mount( - - ) - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - expect(inputRef).to.have.been.calledWith(input.getDOMNode()) - }) - }) - - describe('list', async () => { - it('should provide a ref to the list element', async () => { - const listRef = stub() - - await mount() - const select = await TimeSelectLocator.find() - const input = await select.findInput() - - await input.click() - const list = await select.findOptionsList() - const listbox = await list.find('ul[role="listbox"]') - - expect(listRef).to.have.been.calledWith(listbox.getDOMNode()) - }) - }) - - describe('with generated examples', async () => { - generateA11yTests(TimeSelect, TimeSelectExamples) - }) -}) diff --git a/packages/ui-time-select/tsconfig.build.json b/packages/ui-time-select/tsconfig.build.json index 3ae371930d..44fc589d4a 100644 --- a/packages/ui-time-select/tsconfig.build.json +++ b/packages/ui-time-select/tsconfig.build.json @@ -13,11 +13,11 @@ { "path": "../ui-prop-types/tsconfig.build.json" }, { "path": "../ui-react-utils/tsconfig.build.json" }, { "path": "../ui-select/tsconfig.build.json" }, - { "path": "../ui-test-locator/tsconfig.build.json" }, { "path": "../ui-testable/tsconfig.build.json" }, { "path": "../ui-utils/tsconfig.build.json" }, { "path": "../ui-babel-preset/tsconfig.build.json" }, { "path": "../ui-test-utils/tsconfig.build.json" }, - { "path": "../shared-types/tsconfig.build.json" } + { "path": "../shared-types/tsconfig.build.json" }, + { "path": "../ui-axe-check/tsconfig.build.json" } ] }