diff --git a/package-lock.json b/package-lock.json
index 2500db4a6d..b0a314c96f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54853,10 +54853,13 @@
"prop-types": "^15.8.1"
},
"devDependencies": {
+ "@instructure/ui-axe-check": "^8.45.0",
"@instructure/ui-babel-preset": "8.45.0",
+ "@instructure/ui-scripts": "^8.45.0",
"@instructure/ui-test-locator": "8.45.0",
"@instructure/ui-test-utils": "8.45.0",
- "@instructure/ui-themes": "8.45.0"
+ "@instructure/ui-themes": "8.45.0",
+ "@testing-library/react": "^14.0.0"
},
"peerDependencies": {
"react": ">=16.8 <=18"
@@ -60323,16 +60326,19 @@
"@babel/runtime": "^7.22.15",
"@instructure/emotion": "8.45.0",
"@instructure/shared-types": "8.45.0",
+ "@instructure/ui-axe-check": "^8.45.0",
"@instructure/ui-babel-preset": "8.45.0",
"@instructure/ui-form-field": "8.45.0",
"@instructure/ui-icons": "8.45.0",
"@instructure/ui-react-utils": "8.45.0",
+ "@instructure/ui-scripts": "*",
"@instructure/ui-test-locator": "8.45.0",
"@instructure/ui-test-utils": "8.45.0",
"@instructure/ui-testable": "8.45.0",
"@instructure/ui-themes": "8.45.0",
"@instructure/ui-utils": "8.45.0",
"@instructure/uid": "8.45.0",
+ "@testing-library/react": "^14.0.0",
"keycode": "^2.2.1",
"prop-types": "^15.8.1"
}
diff --git a/packages/ui-number-input/package.json b/packages/ui-number-input/package.json
index 538d19bed1..c433e040c3 100644
--- a/packages/ui-number-input/package.json
+++ b/packages/ui-number-input/package.json
@@ -23,10 +23,13 @@
"ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false"
},
"devDependencies": {
+ "@instructure/ui-axe-check": "^8.45.0",
"@instructure/ui-babel-preset": "8.45.0",
+ "@instructure/ui-scripts": "^8.45.0",
"@instructure/ui-test-locator": "8.45.0",
"@instructure/ui-test-utils": "8.45.0",
- "@instructure/ui-themes": "8.45.0"
+ "@instructure/ui-themes": "8.45.0",
+ "@testing-library/react": "^14.0.0"
},
"dependencies": {
"@babel/runtime": "^7.22.15",
diff --git a/packages/ui-number-input/src/NumberInput/__new-tests__/NumberInput.test.tsx b/packages/ui-number-input/src/NumberInput/__new-tests__/NumberInput.test.tsx
new file mode 100644
index 0000000000..fd066d07a6
--- /dev/null
+++ b/packages/ui-number-input/src/NumberInput/__new-tests__/NumberInput.test.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { render } from '@testing-library/react'
+
+import { runAxeCheck } from '@instructure/ui-axe-check'
+import { NumberInput } from '../index'
+import NumberInputExamples from '../__examples__/NumberInput.examples'
+// eslint-disable-next-line no-restricted-imports
+import { generateA11yTests } from '@instructure/ui-scripts/lib/test/generateA11yTests'
+
+describe('', () => {
+ const generatedComponents = generateA11yTests(
+ NumberInput,
+ NumberInputExamples
+ )
+ for (const component of generatedComponents) {
+ it(component.description, async () => {
+ const { container } = render(component.content)
+ const axeCheck = await runAxeCheck(container)
+ expect(axeCheck).toBe(true)
+ })
+ }
+})
diff --git a/packages/ui-number-input/tsconfig.build.json b/packages/ui-number-input/tsconfig.build.json
index b2806f107e..945a4f3be8 100644
--- a/packages/ui-number-input/tsconfig.build.json
+++ b/packages/ui-number-input/tsconfig.build.json
@@ -10,6 +10,8 @@
{ "path": "../ui-babel-preset/tsconfig.build.json" },
{ "path": "../ui-test-locator/tsconfig.build.json" },
{ "path": "../ui-test-utils/tsconfig.build.json" },
+ { "path": "../ui-scripts/tsconfig.build.json" },
+ { "path": "../ui-axe-check/tsconfig.build.json" },
{ "path": "../ui-themes/tsconfig.build.json" },
{ "path": "../emotion/tsconfig.build.json" },
{ "path": "../shared-types/tsconfig.build.json" },
diff --git a/packages/ui-scripts/lib/test/generateA11yTests.tsx b/packages/ui-scripts/lib/test/generateA11yTests.tsx
new file mode 100644
index 0000000000..966a9094ed
--- /dev/null
+++ b/packages/ui-scripts/lib/test/generateA11yTests.tsx
@@ -0,0 +1,61 @@
+/*
+ * 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 {
+ Example,
+ StoryConfig,
+ generateComponentExamples
+} from './generateComponentExamples'
+
+type ReturnComponentType = {
+ content: React.JSX.Element
+ description: string
+}
+
+const renderExample = ({ Component, componentProps, key }: Example) => (
+
+)
+
+export function generateA11yTests>(
+ Component: React.ComponentType,
+ componentExample: StoryConfig
+): ReturnComponentType[] {
+ const sections = generateComponentExamples(Component, componentExample)
+ const returnComponents: ReturnComponentType[] = []
+ sections.forEach(({ pages }, sectionIndex) => {
+ pages.forEach(({ examples }, pageIndex) => {
+ examples.forEach((example, exampleIndex) => {
+ const Example = renderExample.bind(null, example)
+ const description = `${Component.displayName} example ${
+ sectionIndex * sections.length + pageIndex + 1
+ }/${exampleIndex + 1}`
+ returnComponents.push({
+ content: ,
+ description
+ })
+ })
+ })
+ })
+ return returnComponents
+}
diff --git a/packages/ui-scripts/lib/test/generateComponentExamples.tsx b/packages/ui-scripts/lib/test/generateComponentExamples.tsx
new file mode 100644
index 0000000000..35f0b5ef9e
--- /dev/null
+++ b/packages/ui-scripts/lib/test/generateComponentExamples.tsx
@@ -0,0 +1,325 @@
+/*
+ * 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 { generatePropCombinations } from './generatePropCombinations'
+import React, { ComponentType, ReactNode } from 'react'
+
+export type StoryConfig = {
+ /**
+ * Used to divide the resulting examples into sections. It should correspond
+ * to an enumerated prop in the Component
+ */
+ sectionProp?: keyof Props
+ /**
+ * Specifies the max number of examples that can exist in a single page
+ * within a section
+ */
+ maxExamplesPerPage?: number | ((sectionName: string) => number)
+ /**
+ * Specifies the total max number of examples. Default: 500
+ */
+ maxExamples?: number
+ /**
+ * An object with keys that correspond to the component props. Each key has a
+ * corresponding value array. This array contains possible values for that prop.
+ */
+ propValues?: Partial>
+ /**
+ * Prop keys to exclude from propValues. Useful when generating propValues with code.
+ */
+ excludeProps?: (keyof Props)[]
+ /**
+ * The values returned by this function are passed to the component.
+ * A function called with the prop combination for the current example. It
+ * returns an object of props that will be passed into the `renderExample`
+ * function as componentProps.
+ */
+ getComponentProps?: (props: Props & Record) => Partial
+ /**
+ * The values returned by this function are passed to a `View` that wraps the
+ * example.
+ * A function called with the prop combination for the current example. It
+ * returns an object of props that will be passed into the `renderExample`
+ * function as exampleProps.
+ */
+ getExampleProps?: (props: Props & Record) => Record
+ /**
+ * A function called with the examples and index for the current page of
+ * examples. It returns an object of parameters/metadata for that page of
+ * examples (e.g. to be passed in to a visual regression tool like chromatic).
+ */
+ getParameters?: (params: ExamplesPage) => {
+ [key: string]: any
+ delay?: number
+ disable?: boolean
+ }
+ filter?: (props: Props) => boolean
+}
+
+type ExampleSection = {
+ sectionName: string
+ propName: keyof Props
+ propValue: string
+ pages: ExamplesPage[]
+}
+
+export type ExamplesPage = {
+ examples: Example[]
+ index: number
+ renderExample?: (exampleProps: Example) => ReactNode
+ parameters?: Record
+}
+
+export type Example = {
+ Component: ComponentType
+ componentProps: Partial
+ exampleProps: Record // actually Partial
+ key: string
+}
+
+/**
+ * Generates examples for the given component based on the given configuration.
+ * @param Component A React component
+ * @param config A configuration object (stored in xy.examples.jsx files in InstUI)
+ * @returns Array of examples broken into sections and pages if configured to do so.
+ * @module generateComponentExamples
+ * @private
+ *
+ */
+export function generateComponentExamples>(
+ Component: ComponentType,
+ config: StoryConfig
+) {
+ const { sectionProp, excludeProps, filter } = config
+
+ const PROPS_CACHE: string[] = []
+ const sections: ExampleSection[] = []
+ const maxExamples = config.maxExamples ? config.maxExamples : 500
+ let exampleCount = 0
+ let propValues: Partial> = {}
+
+ const getParameters = (page: ExamplesPage) => {
+ const examples = page.examples
+ const index = page.index
+ let parameters = {}
+ if (typeof config.getParameters === 'function') {
+ parameters = {
+ ...config.getParameters({ examples, index })
+ }
+ }
+ return parameters
+ }
+
+ /**
+ * Merges the auto-generated props with ones in the examples files specified
+ * by the `getComponentProps()` method; props from the example files have
+ * priority
+ */
+ const mergeComponentPropsFromConfig = (props: Props) => {
+ let componentProps = props
+ // TODO this code is so complicated because getComponentProps(props) can return
+ // different values based on its props parameter.
+ // If it would always return the same thing then we could reduce the
+ // number of combinations generated by generatePropCombinations() by
+ // getComponentProps() reducing some to 1 value, it would also remove the
+ // need of PROPS_CACHE and duplicate checks.
+ // InstUI is not using the 'props' param of getComponentProps(), but others are
+ if (typeof config.getComponentProps === 'function') {
+ componentProps = {
+ ...componentProps,
+ ...config.getComponentProps(props)
+ }
+ }
+ return componentProps
+ }
+
+ const getExampleProps = (props: Props) => {
+ let exampleProps: Record = {}
+ if (typeof config.getExampleProps === 'function') {
+ exampleProps = {
+ ...config.getExampleProps(props)
+ }
+ }
+ return exampleProps
+ }
+
+ const addPage = (section: ExampleSection) => {
+ const page: ExamplesPage = {
+ examples: [],
+ index: section.pages.length
+ }
+ section.pages.push(page)
+ return page
+ }
+
+ const addExample = (sectionName: string, example: Example) => {
+ let section = sections.find(
+ (section) => section.sectionName === sectionName
+ )
+ if (!section) {
+ section = {
+ sectionName: sectionName,
+ propName: sectionProp!,
+ propValue: sectionName,
+ pages: []
+ }
+ sections.push(section)
+ }
+
+ let page = section.pages[section.pages.length - 1]
+
+ let { maxExamplesPerPage } = config
+
+ if (typeof maxExamplesPerPage === 'function') {
+ maxExamplesPerPage = maxExamplesPerPage(sectionName)
+ }
+
+ if (!page) {
+ page = addPage(section)
+ } else if (
+ maxExamplesPerPage &&
+ page.examples.length % maxExamplesPerPage === 0 &&
+ page.examples.length > 0
+ ) {
+ page = addPage(section)
+ }
+
+ page.examples.push(example)
+ }
+
+ // Serializes the given recursively, faster than JSON.stringify()
+ const fastSerialize = (props: Props) => {
+ const strArr: string[] = []
+ objToString(props, strArr)
+ return strArr.join('')
+ }
+
+ const objToString = (currObject: any, currString: string[]) => {
+ if (!currObject) {
+ return
+ }
+ if (React.isValidElement(currObject)) {
+ currString.push(JSON.stringify(currObject))
+ } else if (typeof currObject === 'object') {
+ for (const [key, value] of Object.entries(currObject)) {
+ currString.push(key)
+ objToString(value, currString)
+ }
+ } else {
+ currString.push(currObject)
+ }
+ }
+
+ const maybeAddExample = (props: Props): void => {
+ const componentProps = mergeComponentPropsFromConfig(props)
+ const ignore = typeof filter === 'function' ? filter(componentProps) : false
+ if (ignore) {
+ return
+ }
+ const propsString = fastSerialize(componentProps)
+ if (!PROPS_CACHE.includes(propsString)) {
+ const key = `${Date.now()}${Math.round(Math.random() * 10000)}`
+ const exampleProps = getExampleProps(props)
+ exampleCount++
+ if (exampleCount < maxExamples) {
+ PROPS_CACHE.push(propsString)
+ let sectionName = 'Examples'
+ if (sectionProp && componentProps[sectionProp]) {
+ sectionName = componentProps[sectionProp] as unknown as string
+ }
+ addExample(sectionName, {
+ Component,
+ componentProps,
+ exampleProps,
+ key
+ })
+ }
+ }
+ }
+
+ if (isEmpty(config.propValues)) {
+ maybeAddExample({} as Props)
+ } else {
+ if (Array.isArray(excludeProps)) {
+ ;(Object.keys(config.propValues) as (keyof Props)[]).forEach(
+ (propName) => {
+ if (!excludeProps.includes(propName)) {
+ propValues[propName] = config.propValues![propName]
+ }
+ }
+ )
+ } else {
+ propValues = config.propValues
+ }
+ // eslint-disable-next-line no-console
+ console.info(
+ `Generating examples for ${Component.displayName} (${
+ Object.keys(propValues).length
+ } props):`,
+ propValues
+ )
+ // TODO reconcile the differences between these files
+ // generatePropCombinations should call getComponentProps and not do anything?
+ const combos = generatePropCombinations(propValues as any).filter(Boolean)
+ let index = 0
+ while (index < combos.length && exampleCount < maxExamples) {
+ const combo = combos[index]
+ if (combo) {
+ maybeAddExample(combo as Props)
+ index++
+ }
+ }
+ }
+
+ if (exampleCount >= maxExamples) {
+ console.error(
+ `Too many examples for ${Component.displayName}! Add a filter to the config.`
+ )
+ }
+
+ // eslint-disable-next-line no-console
+ console.info(
+ `Generated ${exampleCount} examples for ${Component.displayName}`
+ )
+
+ sections.forEach(({ pages }) => {
+ pages.forEach((page) => {
+ // eslint-disable-next-line no-param-reassign
+ page.parameters = getParameters(page)
+ })
+ })
+ return sections
+}
+
+function isEmpty(
+ obj: unknown
+): obj is null | undefined | Record {
+ if (typeof obj !== 'object') return true
+ for (const key in obj) {
+ if (Object.hasOwnProperty.call(obj, key)) return false
+ }
+ return true
+}
+
+export default generateComponentExamples
diff --git a/packages/ui-scripts/lib/test/generatePropCombinations.ts b/packages/ui-scripts/lib/test/generatePropCombinations.ts
new file mode 100644
index 0000000000..ace5062368
--- /dev/null
+++ b/packages/ui-scripts/lib/test/generatePropCombinations.ts
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+
+type Props = T extends Record ? Record : T
+type ArrayElement = A extends readonly (infer T)[] ? T : never
+
+/**
+ * Given possible values for each prop, returns all combinations of those prop values.
+ * To generate the prop names and values from the component source see the `parsePropValues` utility
+ *
+ * @param {Object} propValues an object with the shape {propName: arrayOfPossibleValues}
+ * @returns {Array} an array of all prop combinations [{propAName: propAValue, propBName: propBValue}]
+ *
+ * @module generatePropCombinations
+ * @private
+ */
+export function generatePropCombinations>(
+ propValues: Props
+) {
+ type PropValueType = ArrayElement[keyof Props]>
+ const propNames = Object.keys(propValues)
+ const combos: Array, PropValueType>> = []
+
+ if (!propNames.length) return combos
+
+ const numProps = propNames.length
+ for (let i = 0; i < numProps; i++) {
+ const propName = propNames[i]
+ const valuesForProp = propValues[propName as keyof Props]
+
+ if (!Array.isArray(valuesForProp) || !valuesForProp.length) {
+ throw new Error(
+ `[ui-examples-loader] Please provide a non-empty array of possible values for
+ prop ${propName}. in "propValues"`
+ )
+ }
+
+ const numValues = valuesForProp.length
+ const numCombos = combos.length
+
+ for (let j = 0; j < numValues; j++) {
+ const propValue = valuesForProp[j]
+ if (numCombos > 0) {
+ for (let k = 0; k < numCombos; k++) {
+ const combo = combos[k]
+
+ // Check against the keys of the object here. `combo[propName]` could
+ // evaluate to a boolean value which will mess up this logic.
+ if (!Object.keys(combo).includes(propName)) {
+ // eslint-disable-next-line no-param-reassign
+ combo[propName as keyof Props] = propValue
+ } else {
+ combos.push({ ...combo, [propName]: propValue })
+ }
+ }
+ } else {
+ //@ts-expect-error TODO: fix this
+ combos.push({ [propName]: propValue as PropValueType })
+ }
+ }
+ }
+ return combos
+}
+
+export default generatePropCombinations