diff --git a/cypress/component/FormField.cy.tsx b/cypress/component/FormField.cy.tsx index ed881373ac..7fa16d82d6 100644 --- a/cypress/component/FormField.cy.tsx +++ b/cypress/component/FormField.cy.tsx @@ -22,6 +22,7 @@ * SOFTWARE. */ import React from 'react' +// eslint-disable-next-line @instructure/no-relative-imports import { FormFieldLayout } from '../../packages/ui' import '../support/component' @@ -36,7 +37,7 @@ describe('', () => { ) - cy.get('span[class$="-formFieldLabel"]') + cy.get('span[class$="-formFieldLayout__label"]') .contains('Username') .should('have.css', 'text-align', 'end') }) @@ -50,7 +51,7 @@ describe('', () => { ) - cy.get('span[class$="-formFieldLabel"]') + cy.get('span[class$="-formFieldLayout__label"]') .contains('Username') .should('have.css', 'text-align', 'start') }) diff --git a/packages/shared-types/src/ComponentThemeVariables.ts b/packages/shared-types/src/ComponentThemeVariables.ts index badc847916..83282dc624 100644 --- a/packages/shared-types/src/ComponentThemeVariables.ts +++ b/packages/shared-types/src/ComponentThemeVariables.ts @@ -595,12 +595,15 @@ export type FormFieldGroupTheme = { errorFieldsPadding: Spacing['xSmall'] } -export type FormFieldLabelTheme = { +export type FormFieldLayoutTheme = { color: Colors['contrasts']['grey125125'] fontFamily: Typography['fontFamily'] fontWeight: Typography['fontWeightBold'] fontSize: Typography['fontSizeMedium'] lineHeight: Typography['lineHeightFit'] + inlinePadding: Spacing['xxSmall'] + stackedOrInlineBreakpoint: Breakpoints['medium'] + spacing: any // TODO remove any } export type FormFieldMessageTheme = { @@ -1748,7 +1751,6 @@ export interface ThemeVariables { FileDrop: FileDropTheme Flex: FlexTheme FormFieldGroup: FormFieldGroupTheme - FormFieldLabel: FormFieldLabelTheme FormFieldMessage: FormFieldMessageTheme FormFieldMessages: FormFieldMessagesTheme Grid: GridTheme diff --git a/packages/ui-checkbox/src/CheckboxGroup/__new-tests__/CheckboxGroup.test.tsx b/packages/ui-checkbox/src/CheckboxGroup/__new-tests__/CheckboxGroup.test.tsx index d35544a8f0..4f45f26a50 100644 --- a/packages/ui-checkbox/src/CheckboxGroup/__new-tests__/CheckboxGroup.test.tsx +++ b/packages/ui-checkbox/src/CheckboxGroup/__new-tests__/CheckboxGroup.test.tsx @@ -88,8 +88,8 @@ describe('', () => { const { container } = renderCheckboxGroup({ messages: [{ text: TEST_ERROR_MESSAGE, type: 'error' }] }) - const fieldset = screen.getByRole('group') - const ariaDesc = fieldset.getAttribute('aria-describedby') + const group = screen.getByRole('group') + const ariaDesc = group.getAttribute('aria-describedby') const messageById = container.querySelector(`[id="${ariaDesc}"]`) expect(messageById).toBeInTheDocument() @@ -98,7 +98,9 @@ describe('', () => { it('displays description message inside the legend', () => { const { container } = renderCheckboxGroup({ description: TEST_DESCRIPTION }) - const legend = container.querySelector('legend') + const legend = container.querySelector( + 'span[class$="-formFieldLayout__label"]' + ) expect(legend).toBeInTheDocument() expect(legend).toHaveTextContent(TEST_DESCRIPTION) @@ -217,5 +219,19 @@ describe('', () => { expect(axeCheck).toBe(true) }) + + it('adds the correct ARIA attributes', () => { + const { container } = renderCheckboxGroup({ + disabled: true, + messages: [{ type: 'newError', text: 'abc' }], + // @ts-ignore This is a valid attribute + 'data-id': 'group' + }) + const group = container.querySelector(`[data-id="group"]`) + expect(group).toBeInTheDocument() + expect(group).toHaveAttribute('aria-disabled', 'true') + // ARIA role 'group' cannot have the 'aria-invalid' attribute + expect(group).not.toHaveAttribute('aria-invalid') + }) }) }) diff --git a/packages/ui-checkbox/src/CheckboxGroup/props.ts b/packages/ui-checkbox/src/CheckboxGroup/props.ts index 16f45377d8..30274fca4b 100644 --- a/packages/ui-checkbox/src/CheckboxGroup/props.ts +++ b/packages/ui-checkbox/src/CheckboxGroup/props.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import React from 'react' +import React, { type InputHTMLAttributes } from 'react' import PropTypes from 'prop-types' import { FormPropTypes } from '@instructure/ui-form-field' @@ -32,7 +32,10 @@ import { } from '@instructure/ui-prop-types' import type { FormMessage } from '@instructure/ui-form-field' -import type { PropValidators } from '@instructure/shared-types' +import type { + OtherHTMLAttributes, + PropValidators +} from '@instructure/shared-types' import type { WithDeterministicIdProps } from '@instructure/ui-react-utils' import { Checkbox } from '../Checkbox' @@ -58,7 +61,12 @@ type PropKeys = keyof CheckboxGroupOwnProps type AllowedPropKeys = Readonly> -type CheckboxGroupProps = CheckboxGroupOwnProps & WithDeterministicIdProps +type CheckboxGroupProps = CheckboxGroupOwnProps & + OtherHTMLAttributes< + CheckboxGroupOwnProps, + InputHTMLAttributes + > & + WithDeterministicIdProps const propTypes: PropValidators = { name: PropTypes.string.isRequired, diff --git a/packages/ui-date-input/src/DateInput/__new-tests__/DateInput.test.tsx b/packages/ui-date-input/src/DateInput/__new-tests__/DateInput.test.tsx index 610169826a..b24ba3a7b0 100644 --- a/packages/ui-date-input/src/DateInput/__new-tests__/DateInput.test.tsx +++ b/packages/ui-date-input/src/DateInput/__new-tests__/DateInput.test.tsx @@ -381,7 +381,9 @@ describe('', () => { {generateDays()} ) - const dateInput = container.querySelector("[class$='-formFieldLabel']") + const dateInput = container.querySelector( + 'span[class$="-formFieldLayout__label"]' + ) expect(dateInput).toHaveTextContent('Choose date') diff --git a/packages/ui-date-input/src/DateInput2/__new-tests__/DateInput2.test.tsx b/packages/ui-date-input/src/DateInput2/__new-tests__/DateInput2.test.tsx index e882a8652b..af99ff059f 100644 --- a/packages/ui-date-input/src/DateInput2/__new-tests__/DateInput2.test.tsx +++ b/packages/ui-date-input/src/DateInput2/__new-tests__/DateInput2.test.tsx @@ -77,12 +77,10 @@ describe('', () => { it('should render an input label', async () => { const { container } = render() - const dateInput = container.querySelector('input') const label = container.querySelector('label') expect(label).toBeInTheDocument() expect(label).toHaveTextContent(LABEL_TEXT) - expect(dateInput?.id).toBe(label?.htmlFor) }) it('should render an input placeholder', async () => { diff --git a/packages/ui-form-field/src/FormField/README.md b/packages/ui-form-field/src/FormField/README.md index 96ee70b224..eb7802a677 100644 --- a/packages/ui-form-field/src/FormField/README.md +++ b/packages/ui-form-field/src/FormField/README.md @@ -9,7 +9,35 @@ components. In most cases it shouldn't be used directly. --- type: example --- - - - +
+ + + + test +
+ + + + test +
+ + + + test +
+ + + + test +
+ hidden text} width="400px" layout="stacked"> + + + test +
+
``` diff --git a/packages/ui-form-field/src/FormField/index.tsx b/packages/ui-form-field/src/FormField/index.tsx index a5cfe3cfa3..04c664dea0 100644 --- a/packages/ui-form-field/src/FormField/index.tsx +++ b/packages/ui-form-field/src/FormField/index.tsx @@ -68,7 +68,6 @@ class FormField extends Component { label={this.props.label} vAlign={this.props.vAlign} as="label" - htmlFor={this.props.id} elementRef={this.handleRef} margin={this.props.margin} /> diff --git a/packages/ui-form-field/src/FormFieldGroup/__new-tests__/FormFieldGroup.test.tsx b/packages/ui-form-field/src/FormFieldGroup/__new-tests__/FormFieldGroup.test.tsx index 908fa83da2..922074e799 100644 --- a/packages/ui-form-field/src/FormFieldGroup/__new-tests__/FormFieldGroup.test.tsx +++ b/packages/ui-form-field/src/FormFieldGroup/__new-tests__/FormFieldGroup.test.tsx @@ -66,7 +66,7 @@ describe('', () => { ) const formFieldGroup = container.querySelector( - "fieldset[class$='-formFieldLayout']" + "span[class$='-formFieldLayout__label']" ) const firstNameInput = screen.getByLabelText('First:') const middleNameInput = screen.getByLabelText('Middle:') @@ -94,9 +94,7 @@ describe('', () => { ) - const formFieldGroup = container.querySelector( - "fieldset[class$='-formFieldLayout']" - ) + const formFieldGroup = container.querySelector('label') expect(formFieldGroup).toBeInTheDocument() }) @@ -136,7 +134,7 @@ describe('', () => { expect(message).toHaveAttribute('id', messagesId) }) - it('displays description message inside the legend', () => { + it('displays description message inside the label', () => { const description = 'Please enter your full name' const { container } = render( @@ -154,7 +152,7 @@ describe('', () => { ) const legend = container.querySelector( - "legend[class$='-screenReaderContent']" + "span[class$='-formFieldLayout__label']" ) expect(legend).toBeInTheDocument() diff --git a/packages/ui-form-field/src/FormFieldGroup/index.tsx b/packages/ui-form-field/src/FormFieldGroup/index.tsx index 16138eb518..f04f83ef99 100644 --- a/packages/ui-form-field/src/FormFieldGroup/index.tsx +++ b/packages/ui-form-field/src/FormFieldGroup/index.tsx @@ -23,7 +23,7 @@ */ /** @jsx jsx */ -import { Component, Children, ReactElement } from 'react' +import { Component, Children, ReactElement, AriaAttributes } from 'react' import { Grid } from '@instructure/ui-grid' import { pickProps, omitProps } from '@instructure/ui-react-utils' @@ -53,7 +53,8 @@ class FormFieldGroup extends Component { disabled: false, rowSpacing: 'medium', colSpacing: 'small', - vAlign: 'middle' + vAlign: 'middle', + isGroup: true } ref: Element | null = null @@ -77,14 +78,20 @@ class FormFieldGroup extends Component { } get makeStylesVariables(): FormFieldGroupStyleProps { - return { invalid: this.invalid } + // new form errors dont need borders + const oldInvalid = + !!this.props.messages && + this.props.messages.findIndex((message) => { + return message.type === 'error' + }) >= 0 + return { invalid: oldInvalid } } get invalid() { return ( !!this.props.messages && this.props.messages.findIndex((message) => { - return message.type === 'error' + return message.type === 'error' || message.type === 'newError' }) >= 0 ) } @@ -134,7 +141,35 @@ class FormFieldGroup extends Component { render() { const { styles, makeStyles, isGroup, ...props } = this.props - + // This is quite ugly, but according to ARIA spec the `aria-invalid` prop + // can only be used with certain roles see + // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid#associated_roles + // `aria-invalid` is put on in FormFieldLayout because the error message + // DOM part gets there its ID. + let ariaInvalid: AriaAttributes['aria-invalid'] = undefined + if ( + this.props.role && + this.invalid && + [ + 'application', + 'checkbox', + 'combobox', + 'gridcell', + 'listbox', + 'radiogroup', + 'slider', + 'spinbutton', + 'textbox', + 'tree', + 'columnheader', + 'rowheader', + 'searchbox', + 'switch', + 'treegrid' + ].includes(this.props.role) + ) { + ariaInvalid = 'true' + } return ( { layout={props.layout === 'inline' ? 'inline' : 'stacked'} label={props.description} aria-disabled={props.disabled ? 'true' : undefined} - aria-invalid={this.invalid ? 'true' : undefined} + aria-invalid={ariaInvalid} elementRef={this.handleRef} isGroup={isGroup} > diff --git a/packages/ui-form-field/src/FormFieldLabel/__new-tests__/FormFieldLabel.test.tsx b/packages/ui-form-field/src/FormFieldLabel/__new-tests__/FormFieldLabel.test.tsx deleted file mode 100644 index b1e8484b3b..0000000000 --- a/packages/ui-form-field/src/FormFieldLabel/__new-tests__/FormFieldLabel.test.tsx +++ /dev/null @@ -1,79 +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 { render } from '@testing-library/react' -import { vi } from 'vitest' -import { runAxeCheck } from '@instructure/ui-axe-check' -import '@testing-library/jest-dom' - -import { FormFieldLabel } from '../index' - -describe('', () => { - let consoleWarningMock: ReturnType - let consoleErrorMock: ReturnType - - beforeEach(() => { - // Mocking console to prevent test output pollution and expect for messages - consoleWarningMock = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}) as any - consoleErrorMock = vi - .spyOn(console, 'error') - .mockImplementation(() => {}) as any - }) - - afterEach(() => { - consoleWarningMock.mockRestore() - consoleErrorMock.mockRestore() - }) - - it('should render', () => { - const { container } = render(Foo) - - const formFieldLabel = container.querySelector( - "span[class$='-formFieldLabel']" - ) - - expect(formFieldLabel).toBeInTheDocument() - expect(formFieldLabel).toHaveTextContent('Foo') - }) - - it('should render as specified via the `as` prop', () => { - const { container } = render(Foo) - - const formFieldLabel = container.querySelector('li') - - expect(formFieldLabel).toBeInTheDocument() - expect(formFieldLabel).toHaveTextContent('Foo') - }) - - it('should meet a11y standards', async () => { - const { container } = render(Foo) - - const axeCheck = await runAxeCheck(container) - - expect(axeCheck).toBe(true) - }) -}) diff --git a/packages/ui-form-field/src/FormFieldLabel/index.tsx b/packages/ui-form-field/src/FormFieldLabel/index.tsx deleted file mode 100644 index 2250bb2d5d..0000000000 --- a/packages/ui-form-field/src/FormFieldLabel/index.tsx +++ /dev/null @@ -1,95 +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. - */ - -/** @jsx jsx */ -import { Component } from 'react' - -import { omitProps, getElementType } from '@instructure/ui-react-utils' -import { withStyle, jsx } from '@instructure/emotion' - -import generateStyle from './styles' -import generateComponentTheme from './theme' - -import { propTypes, allowedProps } from './props' -import type { FormFieldLabelProps } from './props' - -/** ---- -parent: FormField ---- - -This is a helper component that is used by most of the custom form -components. In most cases it shouldn't be used directly. - -```js ---- -type: example ---- -Hello -``` - -**/ -@withStyle(generateStyle, generateComponentTheme) -class FormFieldLabel extends Component { - static readonly componentId = 'FormFieldLabel' - - static propTypes = propTypes - static allowedProps = allowedProps - static defaultProps = { - as: 'span' - } as const - - ref: Element | null = null - - handleRef = (el: Element | null) => { - this.ref = el - } - - componentDidMount() { - this.props.makeStyles?.() - } - - componentDidUpdate() { - this.props.makeStyles?.() - } - - render() { - const ElementType = getElementType(FormFieldLabel, this.props) - - const { styles, children } = this.props - - return ( - - {children} - - ) - } -} - -export default FormFieldLabel -export { FormFieldLabel } diff --git a/packages/ui-form-field/src/FormFieldLabel/props.ts b/packages/ui-form-field/src/FormFieldLabel/props.ts deleted file mode 100644 index 2ff1f89b97..0000000000 --- a/packages/ui-form-field/src/FormFieldLabel/props.ts +++ /dev/null @@ -1,58 +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 PropTypes from 'prop-types' - -import type { - AsElementType, - PropValidators, - FormFieldLabelTheme, - OtherHTMLAttributes -} from '@instructure/shared-types' -import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' - -type FormFieldLabelOwnProps = { - children: React.ReactNode - as?: AsElementType -} - -type PropKeys = keyof FormFieldLabelOwnProps - -type AllowedPropKeys = Readonly> - -type FormFieldLabelProps = FormFieldLabelOwnProps & - WithStyleProps & - OtherHTMLAttributes - -type FormFieldLabelStyle = ComponentStyle<'formFieldLabel'> - -const propTypes: PropValidators = { - children: PropTypes.node.isRequired, - as: PropTypes.elementType -} - -const allowedProps: AllowedPropKeys = ['as', 'children'] - -export type { FormFieldLabelProps, FormFieldLabelStyle } -export { propTypes, allowedProps } diff --git a/packages/ui-form-field/src/FormFieldLabel/styles.ts b/packages/ui-form-field/src/FormFieldLabel/styles.ts deleted file mode 100644 index 4900c1a92e..0000000000 --- a/packages/ui-form-field/src/FormFieldLabel/styles.ts +++ /dev/null @@ -1,74 +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 { hasVisibleChildren } from '@instructure/ui-a11y-utils' - -import type { FormFieldLabelTheme } from '@instructure/shared-types' -import type { FormFieldLabelProps, FormFieldLabelStyle } from './props' - -/** - * --- - * private: true - * --- - * Generates the style object from the theme and provided additional information - * @param {Object} componentTheme The theme variable object. - * @param {Object} props the props of the component, the style is applied to - * @param {Object} state the state of the component, the style is applied to - * @return {Object} The final style object, which will be used in the component - */ -const generateStyle = ( - componentTheme: FormFieldLabelTheme, - props: FormFieldLabelProps -): FormFieldLabelStyle => { - const { children } = props - - const hasContent = hasVisibleChildren(children) - - const labelStyles = { - all: 'initial', - display: 'block', - ...(hasContent && { - color: componentTheme.color, - fontFamily: componentTheme.fontFamily, - fontWeight: componentTheme.fontWeight, - fontSize: componentTheme.fontSize, - lineHeight: componentTheme.lineHeight, - margin: 0, - textAlign: 'inherit' - }) - } - - return { - formFieldLabel: { - label: 'formFieldLabel', - ...labelStyles, - - // NOTE: needs separate groups for `:is()` and `:-webkit-any()` because of css selector group validation (see https://www.w3.org/TR/selectors-3/#grouping) - '&:is(label)': labelStyles, - '&:-webkit-any(label)': labelStyles - } - } -} - -export default generateStyle diff --git a/packages/ui-form-field/src/FormFieldLabel/theme.ts b/packages/ui-form-field/src/FormFieldLabel/theme.ts deleted file mode 100644 index e30f82a61d..0000000000 --- a/packages/ui-form-field/src/FormFieldLabel/theme.ts +++ /dev/null @@ -1,56 +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 type { Theme, ThemeSpecificStyle } from '@instructure/ui-themes' -import { FormFieldLabelTheme } from '@instructure/shared-types' - -/** - * Generates the theme object for the component from the theme and provided additional information - * @param {Object} theme The actual theme object. - * @return {Object} The final theme object with the overrides and component variables - */ -const generateComponentTheme = (theme: Theme): FormFieldLabelTheme => { - const { colors, typography, key: themeName } = theme - - const themeSpecificStyle: ThemeSpecificStyle = { - canvas: { - color: theme['ic-brand-font-color-dark'] - } - } - - const componentVariables: FormFieldLabelTheme = { - color: colors?.contrasts?.grey125125, - fontFamily: typography?.fontFamily, - fontWeight: typography?.fontWeightBold, - fontSize: typography?.fontSizeMedium, - lineHeight: typography?.lineHeightFit - } - - return { - ...componentVariables, - ...themeSpecificStyle[themeName] - } -} - -export default generateComponentTheme diff --git a/packages/ui-form-field/src/FormFieldLayout/__new-tests__/FormFieldLayout.test.tsx b/packages/ui-form-field/src/FormFieldLayout/__new-tests__/FormFieldLayout.test.tsx index c4f1ecd16e..ecb7151b6a 100644 --- a/packages/ui-form-field/src/FormFieldLayout/__new-tests__/FormFieldLayout.test.tsx +++ b/packages/ui-form-field/src/FormFieldLayout/__new-tests__/FormFieldLayout.test.tsx @@ -23,7 +23,7 @@ */ import React from 'react' -import { render, screen } from '@testing-library/react' +import { render } from '@testing-library/react' import { vi } from 'vitest' import { runAxeCheck } from '@instructure/ui-axe-check' import '@testing-library/jest-dom' @@ -56,7 +56,7 @@ describe('', () => { "label[class$='-formFieldLayout']" ) const formFieldLabel = container.querySelector( - "span[class$='-formFieldLabel']" + "span[class$='-formFieldLayout__label']" ) expect(formFieldLayout).toBeInTheDocument() @@ -74,15 +74,13 @@ describe('', () => { it('should provide a ref to the input container', () => { const inputContainerRef = vi.fn() - + const ref = React.createRef() render( - + ) - - const input = screen.getByLabelText('Username') - - expect(inputContainerRef).toHaveBeenCalledWith(input.parentElement) + expect(ref.current).toBeInstanceOf(HTMLInputElement) + expect(inputContainerRef).toHaveBeenCalledWith(ref.current!.parentElement) }) }) diff --git a/packages/ui-form-field/src/FormFieldLayout/index.tsx b/packages/ui-form-field/src/FormFieldLayout/index.tsx index 37b2d160fe..ee6bfc91d2 100644 --- a/packages/ui-form-field/src/FormFieldLayout/index.tsx +++ b/packages/ui-form-field/src/FormFieldLayout/index.tsx @@ -24,25 +24,17 @@ /** @jsx jsx */ import { Component } from 'react' - import { hasVisibleChildren } from '@instructure/ui-a11y-utils' -import { ScreenReaderContent } from '@instructure/ui-a11y-content' -import { Grid } from '@instructure/ui-grid' import { omitProps, - pickProps, getElementType, withDeterministicId } from '@instructure/ui-react-utils' import { withStyle, jsx } from '@instructure/emotion' - -import { FormFieldLabel } from '../FormFieldLabel' import { FormFieldMessages } from '../FormFieldMessages' - import generateStyle from './styles' - -import { propTypes, allowedProps } from './props' +import { propTypes, allowedProps, FormFieldStyleProps } from './props' import type { FormFieldLayoutProps } from './props' import generateComponentTheme from './theme' @@ -68,9 +60,11 @@ class FormFieldLayout extends Component { constructor(props: FormFieldLayoutProps) { super(props) this._messagesId = props.messagesId || props.deterministicId!() + this._labelId = props.deterministicId!('FormField-Label') } private _messagesId: string + private _labelId: string ref: Element | null = null @@ -85,31 +79,51 @@ class FormFieldLayout extends Component { } componentDidMount() { - this.props.makeStyles?.() + this.props.makeStyles?.(this.makeStyleProps()) } componentDidUpdate() { - this.props.makeStyles?.() + this.props.makeStyles?.(this.makeStyleProps()) + } + + makeStyleProps = (): FormFieldStyleProps => { + const hasNewErrorMsgAndIsGroup = + !!this.props.messages?.find((m) => m.type === 'newError') && + !!this.props.isGroup + return { + hasMessages: this.hasMessages, + hasVisibleLabel: this.hasVisibleLabel, + // if true render error message above the controls (and below the label) + hasNewErrorMsgAndIsGroup: hasNewErrorMsgAndIsGroup + } } get hasVisibleLabel() { - return this.props.label && hasVisibleChildren(this.props.label) + return this.props.label ? hasVisibleChildren(this.props.label) : false } get hasMessages() { - return this.props.messages && this.props.messages.length > 0 + if (!this.props.messages || this.props.messages.length == 0) { + return false + } + for (const msg of this.props.messages) { + if (msg.text) { + if (typeof msg.text === 'string') { + return msg.text.length > 0 + } + // this is more complicated (e.g. an array, a React component,...) + // but we don't try to optimize here for these cases + return true + } + } + return false } get elementType() { return getElementType(FormFieldLayout, this.props) } - get inlineContainerAndLabel() { - // Return if both the component container and label will display inline - return this.props.inline && this.props.layout === 'inline' - } - - handleInputContainerRef = (node: HTMLSpanElement | null) => { + handleInputContainerRef = (node: HTMLElement | null) => { if (typeof this.props.inputContainerRef === 'function') { this.props.inputContainerRef(node) } @@ -117,101 +131,73 @@ class FormFieldLayout extends Component { renderLabel() { if (this.hasVisibleLabel) { + if (this.elementType == 'fieldset') { + // `legend` has some special built in CSS, this can only be reset + // this way https://stackoverflow.com/a/65866981/319473 + return ( + + + {this.props.label} + + + ) + } return ( - - - {this.props.label} - - + {this.props.label} ) - } else if (this.elementType !== 'fieldset') { - // to avoid duplicate label/legend content - return this.props.label - } else { - return null - } - } - - renderLegend() { - // note: the legend element must be the first child of a fieldset element for SR - // so we render it twice in that case (once for SR-only and one that is visible) - return ( - - {this.props.label} - {this.hasMessages && ( - - )} - - ) + } else if (this.props.label) { + if (this.elementType == 'fieldset') { + return ( + + {this.props.label} + + ) + } + // needs to be wrapped because it needs an `id` + return
{this.props.label}
+ } else return null } renderVisibleMessages() { return this.hasMessages ? ( - - - - - + ) : null } render() { - // any cast is needed to prevent Expression produces a union type that is too complex to represent errors - const ElementType = this.elementType as any + // Should be `