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" }
]
}