From 792a2cee19a7bd86db0c5ef1f0f53c01ae042b3f Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Thu, 2 Feb 2023 13:55:14 -0500 Subject: [PATCH 1/9] chore(multiple components): updated arialabel on unsupported divs --- .../react-core/src/components/Alert/Alert.tsx | 4 -- .../components/Alert/__tests__/Alert.test.tsx | 58 +++++++++++-------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/react-core/src/components/Alert/Alert.tsx b/packages/react-core/src/components/Alert/Alert.tsx index 6779d28db7c..8cf94023222 100644 --- a/packages/react-core/src/components/Alert/Alert.tsx +++ b/packages/react-core/src/components/Alert/Alert.tsx @@ -27,8 +27,6 @@ export interface AlertProps extends Omit, 'actio * or React.Fragment. */ actionLinks?: React.ReactNode; - /** Adds accessible text to the alert. */ - 'aria-label'?: string; /** Content rendered inside the alert. */ children?: React.ReactNode; /** Additional classes to add to the alert. */ @@ -97,7 +95,6 @@ export const Alert: React.FunctionComponent = ({ isPlain = false, isLiveRegion = false, variantLabel = `${capitalize(variant)} alert:`, - 'aria-label': ariaLabel = `${capitalize(variant)} Alert`, actionClose, actionLinks, title, @@ -229,7 +226,6 @@ export const Alert: React.FunctionComponent = ({ styles.modifiers[variant as 'success' | 'danger' | 'warning' | 'info'], className )} - aria-label={ariaLabel} {...ouiaProps} {...(isLiveRegion && { 'aria-live': 'polite', diff --git a/packages/react-core/src/components/Alert/__tests__/Alert.test.tsx b/packages/react-core/src/components/Alert/__tests__/Alert.test.tsx index f482b53062e..994ef3b231f 100644 --- a/packages/react-core/src/components/Alert/__tests__/Alert.test.tsx +++ b/packages/react-core/src/components/Alert/__tests__/Alert.test.tsx @@ -48,13 +48,9 @@ test('Renders with class pf-c-alert__title on the div containing the title', () expect(screen.getByRole('heading', { name: 'Default alert: Some title' })).toHaveClass('pf-c-alert__title'); }); -test('Renders with a default aria label of Default Alert', () => { - render( - - Some alert - - ); - expect(screen.getByTestId('Alert-test-id')).toHaveAccessibleName('Default Alert'); +test('Renders with default hidden text of "Default alert:"', () => { + render(Some alert); + expect(screen.getByText('Default alert:')).toBeInTheDocument(); }); ['success', 'danger', 'warning', 'info'].forEach(variant => { @@ -76,13 +72,13 @@ test('Renders with a default aria label of Default Alert', () => { expect(screen.getByTestId('Alert-test-id')).toHaveClass(`pf-m-${variant}`); }); - test(`Renders with aria label ${capitalize(variant)} Alert when variant = ${variant}`, () => { + test(`Renders with hidden text "${capitalize(variant)} alert:" when variant = ${variant}`, () => { render( Some alert ); - expect(screen.getByTestId('Alert-test-id')).toHaveAccessibleName(`${capitalize(variant)} Alert`); + expect(screen.getByText(`${capitalize(variant)} alert:`)).toBeInTheDocument(); }); test(`Renders the title with an accessible name of '${capitalize( @@ -136,31 +132,39 @@ test('Renders with a default aria label of Default Alert', () => { }); test('Does not render with class pf-m-inline by default', () => { - render(Some alert); - expect(screen.getByLabelText('Default Alert')).not.toHaveClass('pf-m-inline'); + render( + + Some alert + + ); + expect(screen.getByTestId('Alert-test-id')).not.toHaveClass('pf-m-inline'); }); test('Renders with class pf-m-inline when isInline = true', () => { render( - + Some alert ); - expect(screen.getByLabelText('Default Alert')).toHaveClass('pf-m-inline'); + expect(screen.getByTestId('Alert-test-id')).toHaveClass('pf-m-inline'); }); test('Does not render with class pf-m-plain by default', () => { - render(Some alert); - expect(screen.getByLabelText('Default Alert')).not.toHaveClass('pf-m-plain'); + render( + + Some alert + + ); + expect(screen.getByTestId('Alert-test-id')).not.toHaveClass('pf-m-plain'); }); test('Renders with class pf-m-plain when isPlain = true', () => { render( - + Some alert ); - expect(screen.getByLabelText('Default Alert')).toHaveClass('pf-m-plain'); + expect(screen.getByTestId('Alert-test-id')).toHaveClass('pf-m-plain'); }); test('Renders the title', () => { @@ -588,17 +592,21 @@ test('Passes customIcon value to AlertIcon', () => { }); test('Does not render with class pf-m-expandable by default', () => { - render(Some alert); - expect(screen.getByLabelText('Default Alert')).not.toHaveClass('pf-m-expandable'); + render( + + Some alert + + ); + expect(screen.getByTestId('Alert-test-id')).not.toHaveClass('pf-m-expandable'); }); test('Renders with class pf-m-expandable when isExpandable = true', () => { render( - + Some alert ); - expect(screen.getByLabelText('Default Alert')).toHaveClass('pf-m-expandable'); + expect(screen.getByTestId('Alert-test-id')).toHaveClass('pf-m-expandable'); }); test('Renders AlertToggleExpandButton inside pf-c-alert__toggle', () => { @@ -613,26 +621,26 @@ test('Renders AlertToggleExpandButton inside pf-c-alert__toggle', () => { test('Does not render with class pf-m-expanded when AlertToggleExpandButton has not been clicked', () => { render( - + Some alert ); - expect(screen.getByLabelText('Default Alert')).not.toHaveClass('pf-m-expanded'); + expect(screen.getByTestId('Alert-test-id')).not.toHaveClass('pf-m-expanded'); }); test('Renders with class pf-m-expanded once the AlertToggleExpandButton is clicked', async () => { const user = userEvent.setup(); render( - + Some alert ); await user.click(screen.getByRole('button')); - expect(screen.getByLabelText('Default Alert')).toHaveClass('pf-m-expanded'); + expect(screen.getByTestId('Alert-test-id')).toHaveClass('pf-m-expanded'); }); test('Does not render children when isExpandable = true and AlertToggleExpandButton has not been clicked', () => { From 02514ad977e8004bbc7534d59da0285a17034bc5 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 3 Feb 2023 08:09:33 -0500 Subject: [PATCH 2/9] Updated arialabeling on Wizard --- .../src/components/Wizard/Wizard.tsx | 8 +- .../src/components/Wizard/WizardBody.tsx | 49 ++++- .../src/components/Wizard/WizardToggle.tsx | 8 +- .../__snapshots__/WizardBody.test.tsx.snap | 2 - .../__snapshots__/WizardToggle.test.tsx.snap | 2 - .../Wizard/__tests__/Wizard.test.tsx | 202 ++++++------------ .../__snapshots__/Wizard.test.tsx.snap | 2 - 7 files changed, 128 insertions(+), 145 deletions(-) diff --git a/packages/react-core/src/components/Wizard/Wizard.tsx b/packages/react-core/src/components/Wizard/Wizard.tsx index 0d117a2dc8b..00abede05cf 100644 --- a/packages/react-core/src/components/Wizard/Wizard.tsx +++ b/packages/react-core/src/components/Wizard/Wizard.tsx @@ -78,9 +78,13 @@ export interface WizardProps extends React.HTMLProps { navAriaLabel?: string; /** Sets aria-labelledby on nav element */ navAriaLabelledBy?: string; - /** Aria-label for the main element */ + /** Adds an accessible name to the wizard body when the body content overflows and renders + * a scrollbar. + */ mainAriaLabel?: string; - /** Sets aria-labelledby on the main element */ + /** Adds an accessible name to the wizard body by passing the the id of one or more elements. + * The aria-labelledby will only be applied when the body content overflows and renders a scrollbar. + */ mainAriaLabelledBy?: string; /** Can remove the default padding around the main body content by setting this to true */ hasNoBodyPadding?: boolean; diff --git a/packages/react-core/src/components/Wizard/WizardBody.tsx b/packages/react-core/src/components/Wizard/WizardBody.tsx index 1e42dcc6506..2f13d6a7b75 100644 --- a/packages/react-core/src/components/Wizard/WizardBody.tsx +++ b/packages/react-core/src/components/Wizard/WizardBody.tsx @@ -4,15 +4,21 @@ import { css } from '@patternfly/react-styles'; import { WizardDrawerWrapper } from './WizardDrawerWrapper'; import { Drawer, DrawerContent } from '../Drawer'; import { WizardStep } from './Wizard'; +import { debounce } from '../../helpers/util'; +import { getResizeObserver } from '../../helpers/resizeObserver'; export interface WizardBodyProps { /** Anything that can be rendered in the Wizard body */ children: any; /** Set to true to remove the default body padding */ hasNoBodyPadding: boolean; - /** An aria-label to use for the main element */ + /** Adds an accessible name to the wizard body when the body content overflows and renders + * a scrollbar. + */ 'aria-label'?: string; - /** Sets the aria-labelledby attribute for the main element */ + /** Adds an accessible name to the wizard body by passing the the id of one or more elements. + * The aria-labelledby will only be applied when the body content overflows and renders a scrollbar. + */ 'aria-labelledby': string; /** Component used as the primary content container */ mainComponent?: React.ElementType; @@ -37,8 +43,45 @@ export const WizardBody: React.FunctionComponent = ({ activeStep }: WizardBodyProps) => { const MainComponent = mainComponent; + const [hasScrollbar, setHasScrollbar] = React.useState(false); + const [previousWidth, setPreviousWidth] = React.useState(undefined); + const wizardBodyRef = React.useRef(null); + + React.useEffect(() => { + const resize = () => { + if (wizardBodyRef?.current) { + const { offsetWidth, offsetHeight, scrollHeight } = wizardBodyRef.current; + + if (previousWidth !== offsetWidth) { + setPreviousWidth(offsetWidth); + setHasScrollbar(offsetHeight < scrollHeight); + } + } + }; + + const handleResizeWithDelay = debounce(resize, 250); + let observer = () => {}; + + if (wizardBodyRef?.current) { + observer = getResizeObserver(wizardBodyRef.current, handleResizeWithDelay, false); + const { offsetHeight, scrollHeight } = wizardBodyRef.current; + + setHasScrollbar(offsetHeight < scrollHeight); + setPreviousWidth((wizardBodyRef.current as HTMLElement).offsetWidth); + } + + return () => { + observer(); + }; + }, []); + return ( - + ( diff --git a/packages/react-core/src/components/Wizard/WizardToggle.tsx b/packages/react-core/src/components/Wizard/WizardToggle.tsx index e6e01b40f56..f3fd55a5390 100644 --- a/packages/react-core/src/components/Wizard/WizardToggle.tsx +++ b/packages/react-core/src/components/Wizard/WizardToggle.tsx @@ -23,9 +23,13 @@ export interface WizardToggleProps { onNavToggle: (isOpen: boolean) => void; /** The button's aria-label */ 'aria-label'?: string; - /** Sets aria-labelledby on the main element */ + /** Adds an accessible name to the wizard body by passing the the id of one or more elements. + * The aria-labelledby will only be applied when the body content overflows and renders a scrollbar. + */ mainAriaLabelledBy?: string; - /** The main's aria-label */ + /** Adds an accessible name to the wizard body when the body content overflows and renders + * a scrollbar. + */ mainAriaLabel?: string; /** If the wizard is in-page */ isInPage?: boolean; diff --git a/packages/react-core/src/components/Wizard/__tests__/Generated/__snapshots__/WizardBody.test.tsx.snap b/packages/react-core/src/components/Wizard/__tests__/Generated/__snapshots__/WizardBody.test.tsx.snap index dcc77253fef..5f65ee15ccf 100644 --- a/packages/react-core/src/components/Wizard/__tests__/Generated/__snapshots__/WizardBody.test.tsx.snap +++ b/packages/react-core/src/components/Wizard/__tests__/Generated/__snapshots__/WizardBody.test.tsx.snap @@ -3,8 +3,6 @@ exports[`WizardBody should match snapshot (auto-generated) 1`] = `
{ expect(nav).toHaveAttribute('aria-labelledby', 'pf-wizard-title-5'); }); - test('wiz with title, navAriaLabel, and mainAriaLabel combination', () => { - const steps: WizardStep[] = [{ name: 'A', component:

Step 1

}]; - render( - - ); - - expect(screen.getByRole('heading', { name: 'Wizard' })).toBeInTheDocument(); - expect(screen.getByLabelText('nav aria-label')).toBeInTheDocument(); - expect(screen.getByLabelText('main aria-label')).toBeInTheDocument(); - }); - test('wiz with navAriaLabel and mainAriaLabel but without title', () => { const steps: WizardStep[] = [{ name: 'A', component:

Step 1

}]; - render( - - ); + render(); expect(screen.getByLabelText('nav aria-label')).toBeInTheDocument(); - expect(screen.getByLabelText('main aria-label')).toBeInTheDocument(); }); test('wiz with navAriaLabel and navAriaLabelledby and without title', () => { @@ -170,7 +145,6 @@ describe('Wizard', () => { @@ -182,11 +156,13 @@ describe('Wizard', () => { expect(nav).toHaveAttribute('aria-labelledby', 'nav-aria-labelledby'); }); - test('wiz with navAriaLabel and navAriaLabelledby and without title', () => { + test('wiz with title, navAriaLabelledBy, and navAriaLabel', () => { const steps: WizardStep[] = [{ name: 'A', component:

Step 1

}]; render( { /> ); - const main = screen.getByLabelText('main aria-label'); - - expect(main).toBeInTheDocument(); - expect(main).toHaveAttribute('aria-labelledby', 'main-aria-labelledby'); + const nav = screen.getByLabelText('nav aria-label'); + expect(nav).toBeInTheDocument(); + expect(nav).toHaveAttribute('aria-labelledby', 'nav-aria-labelledby'); }); - test('wiz with title, navAriaLabelledBy, navAriaLabel, mainAriaLabel, and mainAriaLabelledby', () => { + test('Does not render with mainAriaLabel or mainAriaLabelledby by default', () => { const steps: WizardStep[] = [{ name: 'A', component:

Step 1

}]; render( { /> ); - const nav = screen.getByLabelText('nav aria-label'); - expect(nav).toBeInTheDocument(); - expect(nav).toHaveAttribute('aria-labelledby', 'nav-aria-labelledby'); - - const main = screen.getByLabelText('main aria-label'); - expect(main).toBeInTheDocument(); - expect(main).toHaveAttribute('aria-labelledby', 'main-aria-labelledby'); + const main = screen.getByTestId('wizard-test-id'); + expect(main).not.toHaveAttribute('aria-labelledby'); + expect(main).not.toHaveAttribute('aria-label'); }); test('wiz with onCurrentStepChanged setter', async () => { @@ -228,17 +198,11 @@ describe('Wizard', () => { const stepB = { name: 'B', component:

Step 2

}; const stepC = { name: 'C', component:

Step 3

}; - const steps: WizardStep[] = [ stepA, stepB, stepC ]; + const steps: WizardStep[] = [stepA, stepB, stepC]; const setter = jest.fn(); const user = userEvent.setup(); - render( - - ); + render(); expect(setter).toHaveBeenLastCalledWith(expect.objectContaining(stepA)); await user.click(screen.getByText(/next/i)); expect(setter).toHaveBeenLastCalledWith(expect.objectContaining(stepB)); @@ -253,98 +217,72 @@ describe('Wizard', () => { }); }); - test('wiz with disabled steps', () => { - const steps: WizardStep[] = [ - { name: 'A', component:

Step 1

}, - { name: 'B', component:

Step 2

, isDisabled: true }, - { name: 'C', component:

Step 3

}, - { name: 'E', component:

Step 4

, isDisabled: true }, - { name: 'G', component:

Step 5

}, +test('wiz with disabled steps', () => { + const steps: WizardStep[] = [ + { name: 'A', component:

Step 1

}, + { name: 'B', component:

Step 2

, isDisabled: true }, + { name: 'C', component:

Step 3

}, + { name: 'E', component:

Step 4

, isDisabled: true }, + { name: 'G', component:

Step 5

} + ]; - ]; - - render( - - ); + render(); - expect(screen.getByRole('button',{ name: "B" })).toBeDisabled(); - expect(screen.getByRole('button',{ name: "E" })).toBeDisabled(); - }); + expect(screen.getByRole('button', { name: 'B' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'E' })).toBeDisabled(); +}); - test('wiz skip the step disabled when press the next/back button', async () => { - const steps: WizardStep[] = [ - { name: 'A', component:

Step 1

}, - { name: 'B', component:

Step 2

, isDisabled: true }, - { name: 'C', component:

Step 3

}, +test('wiz skip the step disabled when press the next/back button', async () => { + const steps: WizardStep[] = [ + { name: 'A', component:

Step 1

}, + { name: 'B', component:

Step 2

, isDisabled: true }, + { name: 'C', component:

Step 3

} + ]; + const user = userEvent.setup(); - ]; - const user = userEvent.setup(); + render(); - render( - - ); + await user.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByRole('button', { name: 'C' })).toHaveClass('pf-m-current'); - await user.click(screen.getByRole('button', { name: 'Next' })); - expect(screen.getByRole('button',{ name: "C" })).toHaveClass('pf-m-current') + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByRole('button', { name: 'A' })).toHaveClass('pf-m-current'); +}); - await user.click(screen.getByRole('button', { name: 'Back' })); - expect(screen.getByRole('button',{ name: "A" })).toHaveClass('pf-m-current') - }); - - test('wiz skip the step when click on the nav item disabled', async () => { - const steps: WizardStep[] = [ - { name: 'A', component:

Step 1

}, - { name: 'B', component:

Step 2

, isDisabled: true }, - { name: 'C', component:

Step 3

} - ]; - const user = userEvent.setup(); - - render( - - ); +test('wiz skip the step when click on the nav item disabled', async () => { + const steps: WizardStep[] = [ + { name: 'A', component:

Step 1

}, + { name: 'B', component:

Step 2

, isDisabled: true }, + { name: 'C', component:

Step 3

} + ]; + const user = userEvent.setup(); - const navItemButton = screen.getByRole('button',{ name: "B" }) - const navItemButtonSelected = screen.getByRole('button',{ name: "A" }) - - await user.click(navItemButton); + render(); - expect(navItemButton).not.toHaveClass('pf-m-current') - expect(navItemButtonSelected).toHaveClass('pf-m-current') - }); + const navItemButton = screen.getByRole('button', { name: 'B' }); + const navItemButtonSelected = screen.getByRole('button', { name: 'A' }); - test('wiz with disable sub step', () => { - const steps: WizardStep[] = [ - { - name: 'A', - steps: [ - { name: 'A-1', component:

Substep A content

}, - { name: 'A-2', component:

Substep B content

, isDisabled: true } - ] - }, - ]; - - render( - - ); + await user.click(navItemButton); - expect(screen.getByRole('button',{ name: "A-2" })).toBeDisabled(); - }); + expect(navItemButton).not.toHaveClass('pf-m-current'); + expect(navItemButtonSelected).toHaveClass('pf-m-current'); +}); + +test('wiz with disable sub step', () => { + const steps: WizardStep[] = [ + { + name: 'A', + steps: [ + { name: 'A-1', component:

Substep A content

}, + { name: 'A-2', component:

Substep B content

, isDisabled: true } + ] + } + ]; + + render(); + + expect(screen.getByRole('button', { name: 'A-2' })).toBeDisabled(); +}); test('startAtStep can be used to externally control the current step of the wizard', async () => { const WizardTest = () => { @@ -373,7 +311,7 @@ test('startAtStep can be used to externally control the current step of the wiza expect(screen.queryByText('Step 2')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Increment step'})) + await user.click(screen.getByRole('button', { name: 'Increment step' })); expect(screen.getByText('Step 2')).toBeVisible(); }); diff --git a/packages/react-core/src/components/Wizard/__tests__/__snapshots__/Wizard.test.tsx.snap b/packages/react-core/src/components/Wizard/__tests__/__snapshots__/Wizard.test.tsx.snap index 155b4d5da4e..ddc0b8a9b91 100644 --- a/packages/react-core/src/components/Wizard/__tests__/__snapshots__/Wizard.test.tsx.snap +++ b/packages/react-core/src/components/Wizard/__tests__/__snapshots__/Wizard.test.tsx.snap @@ -212,7 +212,6 @@ exports[`Wizard Expandable Nav Wizard should match snapshot 1`] = `
Date: Fri, 3 Feb 2023 09:44:44 -0500 Subject: [PATCH 3/9] Updated arialabeling on Wizard Next --- .../src/components/Wizard/WizardBody.tsx | 2 +- .../src/next/components/Wizard/WizardBody.tsx | 65 ++++++++++++++++--- .../Wizard/__tests__/WizardBody.test.tsx | 10 --- .../Wizard/examples/WizardKitchenSink.tsx | 4 +- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/packages/react-core/src/components/Wizard/WizardBody.tsx b/packages/react-core/src/components/Wizard/WizardBody.tsx index 2f13d6a7b75..9be72a13487 100644 --- a/packages/react-core/src/components/Wizard/WizardBody.tsx +++ b/packages/react-core/src/components/Wizard/WizardBody.tsx @@ -63,7 +63,7 @@ export const WizardBody: React.FunctionComponent = ({ let observer = () => {}; if (wizardBodyRef?.current) { - observer = getResizeObserver(wizardBodyRef.current, handleResizeWithDelay, false); + observer = getResizeObserver(wizardBodyRef.current, handleResizeWithDelay); const { offsetHeight, scrollHeight } = wizardBodyRef.current; setHasScrollbar(offsetHeight < scrollHeight); diff --git a/packages/react-core/src/next/components/Wizard/WizardBody.tsx b/packages/react-core/src/next/components/Wizard/WizardBody.tsx index ab0362bb02b..335b52e26b2 100644 --- a/packages/react-core/src/next/components/Wizard/WizardBody.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardBody.tsx @@ -2,6 +2,9 @@ import React from 'react'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; import { css } from '@patternfly/react-styles'; +import { WizardContext } from './WizardContext'; +import { debounce } from '../../../helpers/util'; +import { getResizeObserver } from '../../../helpers/resizeObserver'; /** * Used as a wrapper for WizardStep content, where the wrapping element is customizable. @@ -11,9 +14,13 @@ export interface WizardBodyProps { children: React.ReactNode | React.ReactNode[]; /** Set to true to remove the default body padding */ hasNoPadding?: boolean; - /** An aria-label to use for the wrapper element */ + /** Adds an accessible name to the wrapper element when the content overflows and renders + * a scrollbar. + */ 'aria-label'?: string; - /** Sets the aria-labelledby attribute for the wrapper element */ + /** Adds an accessible name to the wrapper element by passing the the id of one or more elements. + * The aria-labelledby will only be applied when the content overflows and renders a scrollbar. + */ 'aria-labelledby'?: string; /** Component used as the wrapping content container */ component?: React.ElementType; @@ -24,11 +31,53 @@ export const WizardBody = ({ hasNoPadding = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, - component: WrapperComponent = 'div' -}: WizardBodyProps) => ( - -
{children}
-
-); + component = 'div' +}: WizardBodyProps) => { + const [hasScrollbar, setHasScrollbar] = React.useState(false); + const [previousWidth, setPreviousWidth] = React.useState(undefined); + const wrapperRef = React.useRef(null); + const WrapperComponent = component; + const { activeStep } = React.useContext(WizardContext); + const defaultAriaLabel = ariaLabel || `${activeStep?.name} content`; + + React.useEffect(() => { + const resize = () => { + if (wrapperRef?.current) { + const { offsetWidth, offsetHeight, scrollHeight } = wrapperRef.current; + + if (previousWidth !== offsetWidth) { + setPreviousWidth(offsetWidth); + setHasScrollbar(offsetHeight < scrollHeight); + } + } + }; + + const handleResizeWithDelay = debounce(resize, 250); + let observer = () => {}; + + if (wrapperRef?.current) { + observer = getResizeObserver(wrapperRef.current, handleResizeWithDelay); + const { offsetHeight, scrollHeight } = wrapperRef.current; + + setHasScrollbar(offsetHeight < scrollHeight); + setPreviousWidth((wrapperRef.current as HTMLElement).offsetWidth); + } + + return () => { + observer(); + }; + }, []); + + return ( + +
{children}
+
+ ); +}; WizardBody.displayName = 'WizardBody'; diff --git a/packages/react-core/src/next/components/Wizard/__tests__/WizardBody.test.tsx b/packages/react-core/src/next/components/Wizard/__tests__/WizardBody.test.tsx index 50ca3355675..1bf85474455 100644 --- a/packages/react-core/src/next/components/Wizard/__tests__/WizardBody.test.tsx +++ b/packages/react-core/src/next/components/Wizard/__tests__/WizardBody.test.tsx @@ -20,16 +20,6 @@ test('has padding className when hasNoPadding is specified', () => { expect(screen.getByText('content')).toHaveClass('pf-m-no-padding'); }); -test('has aria-label when one is specified', () => { - render(content); - expect(screen.getByLabelText('Body label')).toBeVisible(); -}); - -test('has aria-labelledby when one is specified', () => { - const { container } = render(content); - expect(container.firstElementChild).toHaveAttribute('aria-labelledby', 'some-id'); -}); - test('wrapper element is of type div when component is not specified', () => { const { container } = render(content); expect(container.firstElementChild?.tagName).toEqual('DIV'); diff --git a/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx index e5daf6b1ed2..f8ae854b257 100644 --- a/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx @@ -99,8 +99,8 @@ const StepContentWithDrawer = () => { )} - - + + From 274d0af39040fcd93a4309bb4da898eeddf9512d Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 3 Feb 2023 11:30:39 -0500 Subject: [PATCH 4/9] Updated arialabeling on Accordion, Menu, and Page components --- packages/react-core/src/components/Accordion/Accordion.tsx | 3 ++- packages/react-core/src/components/Menu/Menu.tsx | 4 ---- packages/react-core/src/components/Menu/MenuItem.tsx | 6 ++++-- packages/react-core/src/components/Menu/MenuList.tsx | 4 ++++ packages/react-core/src/components/Page/PageGroup.tsx | 5 ++--- packages/react-core/src/components/Page/PageNavigation.tsx | 5 ++--- .../react-core/src/components/Page/__tests__/Page.test.tsx | 7 ++++++- .../src/components/Page/__tests__/PageGroup.test.tsx | 6 +++++- .../src/components/Page/__tests__/PageNavigation.test.tsx | 6 +++++- .../Page/__tests__/__snapshots__/Page.test.tsx.snap | 4 +++- .../Page/__tests__/__snapshots__/PageGroup.test.tsx.snap | 1 + .../__tests__/__snapshots__/PageNavigation.test.tsx.snap | 1 + 12 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/react-core/src/components/Accordion/Accordion.tsx b/packages/react-core/src/components/Accordion/Accordion.tsx index 1ed023e5903..386106770ed 100644 --- a/packages/react-core/src/components/Accordion/Accordion.tsx +++ b/packages/react-core/src/components/Accordion/Accordion.tsx @@ -23,7 +23,7 @@ export interface AccordionProps extends React.HTMLProps { export const Accordion: React.FunctionComponent = ({ children = null, className = '', - 'aria-label': ariaLabel = '', + 'aria-label': ariaLabel, headingLevel = 'h3', asDefinitionList = true, isBordered = false, @@ -40,6 +40,7 @@ export const Accordion: React.FunctionComponent = ({ className )} aria-label={ariaLabel} + {...(!asDefinitionList && ariaLabel && { role: 'region' })} {...props} > , 'r event: React.FormEvent | React.SyntheticEvent, value: string ) => void; - /** Accessibility label */ - 'aria-label'?: string; /** @beta Indicates if menu contains a flyout menu */ containsFlyout?: boolean; /** @beta Indicating that the menu should have nav flyout styling */ @@ -250,7 +248,6 @@ class MenuBase extends React.Component { render() { const { - 'aria-label': ariaLabel, id, children, className, @@ -339,7 +336,6 @@ class MenuBase extends React.Component { _isMenuDrilledIn && styles.modifiers.drilledIn, className )} - aria-label={ariaLabel} ref={this.menuRef} {...getOUIAProps(Menu.displayName, ouiaId !== undefined ? ouiaId : this.state.ouiaStateId, ouiaSafe)} {...props} diff --git a/packages/react-core/src/components/Menu/MenuItem.tsx b/packages/react-core/src/components/Menu/MenuItem.tsx index ec2d04c82bf..d2a90ee8dfc 100644 --- a/packages/react-core/src/components/Menu/MenuItem.tsx +++ b/packages/react-core/src/components/Menu/MenuItem.tsx @@ -64,7 +64,7 @@ export interface MenuItemProps extends Omit, 'onC direction?: 'down' | 'up'; /** @beta True if item is on current selection path */ isOnPath?: boolean; - /** Accessibility label */ + /** Adds an accessible name to the menu item. */ 'aria-label'?: string; /** @hide Forwarded ref */ innerRef?: React.Ref; @@ -103,6 +103,7 @@ const MenuItemBase: React.FunctionComponent = ({ isOnPath, innerRef, id, + 'aria-label': ariaLabel, ...props }: MenuItemProps) => { const { @@ -312,6 +313,7 @@ const MenuItemBase: React.FunctionComponent = ({ {...(flyoutMenu && { onKeyDown: handleFlyout })} ref={ref} role={!hasCheckbox ? 'none' : 'menuitem'} + {...(hasCheckbox && { 'aria-label': ariaLabel })} {...props} > @@ -321,7 +323,7 @@ const MenuItemBase: React.FunctionComponent = ({ tabIndex={-1} className={css(styles.menuItem, getIsSelected() && !hasCheckbox && styles.modifiers.selected, className)} aria-current={getAriaCurrent()} - {...(!hasCheckbox && { disabled: isDisabled })} + {...(!hasCheckbox && { disabled: isDisabled, 'aria-label': ariaLabel })} {...(!hasCheckbox && !flyoutMenu && { role: isSelectMenu ? 'option' : 'menuitem' })} {...(!hasCheckbox && !flyoutMenu && isSelectMenu && { 'aria-selected': getIsSelected() })} ref={innerRef} diff --git a/packages/react-core/src/components/Menu/MenuList.tsx b/packages/react-core/src/components/Menu/MenuList.tsx index cf04435e130..c45d9a6bc68 100644 --- a/packages/react-core/src/components/Menu/MenuList.tsx +++ b/packages/react-core/src/components/Menu/MenuList.tsx @@ -12,12 +12,15 @@ export interface MenuListProps extends React.HTMLProps { * for a non-checkbox menu. Only applies when the menu's role is "listbox". */ isAriaMultiselectable?: boolean; + /** Adds an accessible name to the menu. */ + 'aria-label'?: string; } export const MenuList: React.FunctionComponent = ({ children = null, className, isAriaMultiselectable = false, + 'aria-label': ariaLabel, ...props }: MenuListProps) => { const { role } = React.useContext(MenuContext); @@ -27,6 +30,7 @@ export const MenuList: React.FunctionComponent = ({ role={role} {...(role === 'listbox' && { 'aria-multiselectable': isAriaMultiselectable })} className={css(styles.menuList, className)} + aria-label={ariaLabel} {...props} > {children} diff --git a/packages/react-core/src/components/Page/PageGroup.tsx b/packages/react-core/src/components/Page/PageGroup.tsx index 1b88bf5083d..136c678d512 100644 --- a/packages/react-core/src/components/Page/PageGroup.tsx +++ b/packages/react-core/src/components/Page/PageGroup.tsx @@ -23,7 +23,7 @@ export interface PageGroupProps extends React.HTMLProps { hasShadowBottom?: boolean; /** Flag indicating if the PageGroup has a scrolling overflow */ hasOverflowScroll?: boolean; - /** Adds an accessible name to the page group. Required when the hasOverflowScroll prop is set to true. */ + /** Adds an accessible name to the page group when the hasOverflowScroll prop is set to true. */ 'aria-label'?: string; } @@ -57,8 +57,7 @@ export const PageGroup = ({ hasOverflowScroll && styles.modifiers.overflowScroll, className )} - {...(hasOverflowScroll && { tabIndex: 0 })} - aria-label={ariaLabel} + {...(hasOverflowScroll && { tabIndex: 0, role: 'region', 'aria-label': ariaLabel })} > {children}
diff --git a/packages/react-core/src/components/Page/PageNavigation.tsx b/packages/react-core/src/components/Page/PageNavigation.tsx index 9024c1fe4d0..33845792385 100644 --- a/packages/react-core/src/components/Page/PageNavigation.tsx +++ b/packages/react-core/src/components/Page/PageNavigation.tsx @@ -26,7 +26,7 @@ export interface PageNavigationProps extends React.HTMLProps { hasShadowBottom?: boolean; /** Flag indicating if the PageNavigation has a scrolling overflow */ hasOverflowScroll?: boolean; - /** Adds an accessible name to the page navigation. Required when the hasOverflowScroll prop is set to true. */ + /** Adds an accessible name to the page navigation when the hasOverflowScroll prop is set to true. */ 'aria-label'?: string; } @@ -61,8 +61,7 @@ export const PageNavigation = ({ hasOverflowScroll && styles.modifiers.overflowScroll, className )} - {...(hasOverflowScroll && { tabIndex: 0 })} - aria-label={ariaLabel} + {...(hasOverflowScroll && { tabIndex: 0, role: 'region', 'aria-label': ariaLabel })} {...props} > {isWidthLimited &&
{children}
} diff --git a/packages/react-core/src/components/Page/__tests__/Page.test.tsx b/packages/react-core/src/components/Page/__tests__/Page.test.tsx index 529f912fb4b..2c0afa01b65 100644 --- a/packages/react-core/src/components/Page/__tests__/Page.test.tsx +++ b/packages/react-core/src/components/Page/__tests__/Page.test.tsx @@ -356,7 +356,12 @@ describe('Page', () => { tertiaryNav={nav} isBreadcrumbGrouped isTertiaryNavGrouped - groupProps={{ stickyOnBreakpoint: { default: 'bottom' }, hasShadowTop: true, 'aria-label': 'test' }} + groupProps={{ + stickyOnBreakpoint: { default: 'bottom' }, + hasShadowTop: true, + hasOverflowScroll: true, + 'aria-label': 'test' + }} > Section with default background Section with light background diff --git a/packages/react-core/src/components/Page/__tests__/PageGroup.test.tsx b/packages/react-core/src/components/Page/__tests__/PageGroup.test.tsx index 8ac64dd01c2..4415d1058a2 100644 --- a/packages/react-core/src/components/Page/__tests__/PageGroup.test.tsx +++ b/packages/react-core/src/components/Page/__tests__/PageGroup.test.tsx @@ -35,7 +35,11 @@ describe('page group', () => { }); test('Renders with the passed aria-label applied', () => { - render(test); + render( + + test + + ); expect(screen.getByText('test')).toHaveAccessibleName('Test label'); }); diff --git a/packages/react-core/src/components/Page/__tests__/PageNavigation.test.tsx b/packages/react-core/src/components/Page/__tests__/PageNavigation.test.tsx index 8577c1cc4d5..8f9d77dbe0a 100644 --- a/packages/react-core/src/components/Page/__tests__/PageNavigation.test.tsx +++ b/packages/react-core/src/components/Page/__tests__/PageNavigation.test.tsx @@ -39,7 +39,11 @@ describe('page navigation', () => { }); test('Renders with the passed aria-label applied', () => { - render(test); + render( + + test + + ); expect(screen.getByText('test')).toHaveAccessibleName('Test label'); }); diff --git a/packages/react-core/src/components/Page/__tests__/__snapshots__/Page.test.tsx.snap b/packages/react-core/src/components/Page/__tests__/__snapshots__/Page.test.tsx.snap index d4cc62955ed..fdd2a8a5373 100644 --- a/packages/react-core/src/components/Page/__tests__/__snapshots__/Page.test.tsx.snap +++ b/packages/react-core/src/components/Page/__tests__/__snapshots__/Page.test.tsx.snap @@ -786,7 +786,9 @@ exports[`Page Check page to verify grouped nav and breadcrumb - old / props synt >
test diff --git a/packages/react-core/src/components/Page/__tests__/__snapshots__/PageNavigation.test.tsx.snap b/packages/react-core/src/components/Page/__tests__/__snapshots__/PageNavigation.test.tsx.snap index b93ca524104..1d165e02e8c 100644 --- a/packages/react-core/src/components/Page/__tests__/__snapshots__/PageNavigation.test.tsx.snap +++ b/packages/react-core/src/components/Page/__tests__/__snapshots__/PageNavigation.test.tsx.snap @@ -48,6 +48,7 @@ exports[`page navigation Verify overflow scroll 1`] = `
test From c9164ba1b66e0a48cf6a6795a279e4e8c22f8961 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 3 Feb 2023 11:43:59 -0500 Subject: [PATCH 5/9] Updated arialabelledby on ProgressStep --- .../src/components/ProgressStepper/ProgressStep.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-core/src/components/ProgressStepper/ProgressStep.tsx b/packages/react-core/src/components/ProgressStepper/ProgressStep.tsx index a0882eaf178..c8a1329cc84 100644 --- a/packages/react-core/src/components/ProgressStepper/ProgressStep.tsx +++ b/packages/react-core/src/components/ProgressStepper/ProgressStep.tsx @@ -100,7 +100,9 @@ export const ProgressStep: React.FunctionComponent = ({ id={titleId} ref={stepRef} {...(popoverRender && { type: 'button' })} - {...(props.id !== undefined && titleId !== undefined && { 'aria-labelledby': `${props.id} ${titleId}` })} + {...(props.id !== undefined && + titleId !== undefined && + popoverRender && { 'aria-labelledby': `${props.id} ${titleId}` })} > {children} {popoverRender && popoverRender(stepRef)} From 4260f1d2fe8f365f3fea64653d13b028af4ef9dc Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 3 Feb 2023 13:38:08 -0500 Subject: [PATCH 6/9] Added integration test for Wizard focus functionality --- .../cypress/integration/wizard.spec.ts | 24 ++++++++++ .../demos/WizardDemo/WizardDemo.tsx | 44 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/react-integration/cypress/integration/wizard.spec.ts b/packages/react-integration/cypress/integration/wizard.spec.ts index 686d4772301..027dbf5603d 100644 --- a/packages/react-integration/cypress/integration/wizard.spec.ts +++ b/packages/react-integration/cypress/integration/wizard.spec.ts @@ -20,4 +20,28 @@ describe('Wizard Demo Test', () => { it('Verify in page wizard displays on page render', () => { cy.get('#inPageWizId.pf-c-wizard').should('exist'); }); + + it('Verify wizard step content is focusable only if content overflows', () => { + cy.get('#wizard-focusable-overflow .pf-c-wizard__main').should('not.have.attr', 'tabindex'); + cy.get('#wizard-focusable-overflow .pf-c-wizard__main').click(); + cy.get('#wizard-focusable-overflow .pf-c-wizard__main').should('not.have.focus'); + cy.get('#wizard-focusable-overflow button.pf-c-wizard__nav-link') + .last() + .click(); + cy.get('#wizard-focusable-overflow .pf-c-wizard__main').should('have.attr', 'tabindex'); + cy.get('#wizard-focusable-overflow .pf-c-wizard__main').click(); + cy.get('#wizard-focusable-overflow .pf-c-wizard__main').should('have.focus'); + }); + + it('Verify role attribute is applied correctly', () => { + cy.get('#wizard-correct-role .pf-c-wizard__main').should('not.have.attr', 'role'); + cy.get('#wizard-correct-role button.pf-c-wizard__nav-link') + .last() + .click(); + cy.get('#wizard-correct-role .pf-c-wizard__main').should('have.attr', 'role'); + cy.get('#wizard-correct-role .pf-c-wizard__main button').click(); + // Within a modal, wizard body uses the
element and should not have a role applied + cy.get('#wizard-correct-role .pf-c-wizard__main').should('not.have.attr', 'role'); + cy.get('#wizard-correct-role .pf-c-wizard__close').click(); + }); }); diff --git a/packages/react-integration/demo-app-ts/src/components/demos/WizardDemo/WizardDemo.tsx b/packages/react-integration/demo-app-ts/src/components/demos/WizardDemo/WizardDemo.tsx index 7983849175f..f8b11e369fa 100644 --- a/packages/react-integration/demo-app-ts/src/components/demos/WizardDemo/WizardDemo.tsx +++ b/packages/react-integration/demo-app-ts/src/components/demos/WizardDemo/WizardDemo.tsx @@ -3,12 +3,14 @@ import React from 'react'; interface WizardDemoState { isOpen: boolean; + isOpenWithRole: boolean; } export class WizardDemo extends React.Component, WizardDemoState> { static displayName = 'WizardDemo'; state = { - isOpen: false + isOpen: false, + isOpenWithRole: false }; handleModalToggle = () => { @@ -16,6 +18,10 @@ export class WizardDemo extends React.Component, isOpen: !isOpen })); }; + + handleRoleWizardToggle = () => { + this.setState(({ isOpenWithRole }) => ({ isOpenWithRole: !isOpenWithRole })); + }; componentDidMount() { window.scrollTo(0, 0); } @@ -59,6 +65,22 @@ export class WizardDemo extends React.Component, stepNavItemProps: { navItemComponent: 'button', href: 'hhttps://www.patternfly.org/v4/' } } ]; + + const stepsOnOverflow: WizardStep[] = [ + { + name: 'Step without overflow', + component:

Step 1

+ }, + { + name: 'Step with overflow', + component: ( +
+

Step 2

+ +
+ ) + } + ]; return ( - )} - - - - - - - - - ); -}; - -const CustomStepThreeFooter = () => { - const { onNext: goToNextStep, onBack, onClose } = useWizardContext(); - const [isLoading, setIsLoading] = React.useState(false); - - async function onNext(goToStep: () => void) { - setIsLoading(true); - await new Promise(resolve => setTimeout(resolve, 2000)); - setIsLoading(false); - - goToStep(); - } - - return ( - - - - - - ); -}; - -const StepContentWithActions = () => { - const { isToggleStepChecked, errorMessage, setIsToggleStepChecked, setErrorMessage } = React.useContext(SomeContext); - - return ( - <> - setErrorMessage(checked ? 'Some error message' : undefined)} - id="toggle-error-checkbox" - name="Toggle Error Checkbox" - /> - setIsToggleStepChecked(checked)} - id="toggle-hide-step-checkbox" - name="Toggle Hide Step Checkbox" - /> - - ); -}; - -export const WizardKitchenSink: React.FunctionComponent = () => { - const onNext: WizardNavStepFunction = (_currentStep: WizardNavStepData, _previousStep: WizardNavStepData) => {}; - const [isSubmitting, setIsSubmitting] = React.useState(false); - - async function onSubmit(): Promise { - setIsSubmitting(true); - - await new Promise(resolve => setTimeout(resolve, 5000)); - - setIsSubmitting(false); - alert('50 points to Gryffindor!'); - } - - return ( - - {({ isToggleStepChecked, errorMessage }) => ( - } - footer={} - onNext={onNext} - isStepVisitRequired - > - - - - - - , - - Substep 2 content - - ]} - /> - Custom item - }} - footer={} - > - Step 3 content w/ custom async footer - - - {isSubmitting ? 'Calculating wizard score...' : 'Review step content'} - - - )} - - ); -}; From 032b3233cf5e19df74266682592f03a133c8583c Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Thu, 16 Feb 2023 09:15:37 -0500 Subject: [PATCH 9/9] Updated basic examples --- .../Wizard/examples/WizardBasic.tsx | 27 ++++++++++++++++++- .../Wizard/examples/WizardBasic.tsx | 20 +++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/react-core/src/components/Wizard/examples/WizardBasic.tsx b/packages/react-core/src/components/Wizard/examples/WizardBasic.tsx index 71eca4176f5..0bbd2e582f1 100644 --- a/packages/react-core/src/components/Wizard/examples/WizardBasic.tsx +++ b/packages/react-core/src/components/Wizard/examples/WizardBasic.tsx @@ -3,7 +3,32 @@ import { Wizard } from '@patternfly/react-core'; export const WizardBasic: React.FunctionComponent = () => { const steps = [ - { name: 'First step', component:

Step 1 content

}, + { + name: 'First step', + component: ( + <> +

+ The content of this step overflows and creates a scrollbar, which causes a tabindex of "0", a role of + "region", and an aria-label or aria-labelledby to be applied. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer bibendum in neque nec pharetra. Duis + lacinia vel sapien ut imperdiet. Nunc ultrices mollis dictum. Duis tempus, massa nec tincidunt tempor, enim + ex porttitor odio, eu facilisis dolor tortor id sapien. Etiam sit amet molestie lacus. Nulla facilisi. Duis + eget finibus ipsum. Quisque dictum enim sed sodales porta. Curabitur eget orci eu risus posuere pulvinar id + nec turpis. Morbi mattis orci vel posuere tincidunt. Fusce bibendum et libero a auctor. +

+

+ Proin elementum commodo sodales. Quisque eget libero mattis, ornare augue at, egestas nisi. Mauris ultrices + orci fringilla pretium mattis. Aliquam erat volutpat. Sed pharetra condimentum dui, nec bibendum ante. + Vestibulum sollicitudin, sem accumsan pharetra molestie, purus turpis lacinia lorem, commodo sodales quam + lectus a urna. Nam gravida, felis a lacinia varius, ex ipsum ultrices orci, non egestas diam velit in mi. Ut + sit amet commodo orci. Duis sed diam odio. Duis mi metus, dignissim in odio nec, ornare aliquet libero. Sed + luctus elit nibh. Quisque et felis diam. Integer ac metus dolor. +

+ + ) + }, { name: 'Second step', component:

Step 2 content

}, { name: 'Third step', component:

Step 3 content

}, { name: 'Fourth step', component:

Step 4 content

}, diff --git a/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx index d9cf3055a15..499e4c7a44c 100644 --- a/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx @@ -4,7 +4,25 @@ import { Wizard, WizardStep } from '@patternfly/react-core/next'; export const WizardBasic: React.FunctionComponent = () => ( - Step 1 content +

+ The content of this step overflows and creates a scrollbar, which causes a tabindex of "0", a role of "region", + and an aria-label or aria-labelledby to be applied. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer bibendum in neque nec pharetra. Duis lacinia + vel sapien ut imperdiet. Nunc ultrices mollis dictum. Duis tempus, massa nec tincidunt tempor, enim ex porttitor + odio, eu facilisis dolor tortor id sapien. Etiam sit amet molestie lacus. Nulla facilisi. Duis eget finibus + ipsum. Quisque dictum enim sed sodales porta. Curabitur eget orci eu risus posuere pulvinar id nec turpis. Morbi + mattis orci vel posuere tincidunt. Fusce bibendum et libero a auctor. +

+

+ Proin elementum commodo sodales. Quisque eget libero mattis, ornare augue at, egestas nisi. Mauris ultrices orci + fringilla pretium mattis. Aliquam erat volutpat. Sed pharetra condimentum dui, nec bibendum ante. Vestibulum + sollicitudin, sem accumsan pharetra molestie, purus turpis lacinia lorem, commodo sodales quam lectus a urna. + Nam gravida, felis a lacinia varius, ex ipsum ultrices orci, non egestas diam velit in mi. Ut sit amet commodo + orci. Duis sed diam odio. Duis mi metus, dignissim in odio nec, ornare aliquet libero. Sed luctus elit nibh. + Quisque et felis diam. Integer ac metus dolor. +

Step 2 content