diff --git a/change/@fluentui-react-components-ee7b5914-40cd-4950-b46d-d75c94ad4037.json b/change/@fluentui-react-components-ee7b5914-40cd-4950-b46d-d75c94ad4037.json new file mode 100644 index 00000000000000..19e1c9cad68d67 --- /dev/null +++ b/change/@fluentui-react-components-ee7b5914-40cd-4950-b46d-d75c94ad4037.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Add uncomplete Input scenario", + "packageName": "@fluentui/react-components", + "email": "adam.samec@gmail.com", + "dependentChangeType": "patch" +} diff --git a/package.json b/package.json index 9fa49944f59fb9..25407ad3e76cb9 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@babel/preset-typescript": "7.14.5", "@babel/register": "7.14.5", "@babel/standalone": "7.14.8", + "@cactuslab/usepubsub": "^1.0.2", "@ctrl/tinycolor": "3.3.4", "@cypress/react": "5.12.4", "@cypress/webpack-dev-server": "1.8.3", diff --git a/packages/react-components/react-components/package.json b/packages/react-components/react-components/package.json index 7fd7a029f93dff..49d43eb36020b9 100644 --- a/packages/react-components/react-components/package.json +++ b/packages/react-components/react-components/package.json @@ -28,7 +28,8 @@ "devDependencies": { "@fluentui/eslint-plugin": "*", "@fluentui/react-storybook-addon": "9.0.0-rc.1", - "@fluentui/scripts": "^1.0.0" + "@fluentui/scripts": "^1.0.0", + "react-hook-form": "^5.7.2" }, "dependencies": { "@fluentui/react-accordion": "9.0.0-rc.9", diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Accordion.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/Accordion.stories.tsx new file mode 100644 index 00000000000000..b8926ba0076885 --- /dev/null +++ b/packages/react-components/react-components/src/AccessibilityScenarios/Accordion.stories.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; + +import { Accordion, AccordionItem, AccordionHeader, AccordionPanel } from '@fluentui/react-accordion'; +import { Label } from '@fluentui/react-label'; +import { Input } from '@fluentui/react-input'; +import { Button } from '@fluentui/react-button'; + +import { Scenario } from './utils'; + +export const PersonalFormAccordionAccessibilityScenario: React.FunctionComponent = () => { + return ( + + Personal form + + + + Basic information + + Name: + + Email: + + + Age + + Below 18 + + Between 18 and 30 + + Over 30 + + + + + Residence + + Street: + + City: + + Country: + + + + + Hobbies + + + + books + + sports + + music + + travelling + + + + + Submit + + + ); +}; + +export default { + title: 'Accessibility Scenarios / Personal form accordion', + id: 'accordion-accessibility-scenario', +}; diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Accordion1.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/Accordion1.stories.tsx deleted file mode 100644 index dd28055be70584..00000000000000 --- a/packages/react-components/react-components/src/AccessibilityScenarios/Accordion1.stories.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import * as React from 'react'; - -import { Accordion, AccordionItem, AccordionHeader, AccordionPanel } from '@fluentui/react-accordion'; - -import { Label } from '@fluentui/react-label'; - -import { Button } from '@fluentui/react-button'; - -import { Scenario } from './utils'; - -export const PersonalFormAccordionAccessibilityScenario: React.FunctionComponent = () => { - return ( - - Personal form - - - Basic information - - Name: - - Email: - - - Age - - Below 18 - - Between 18 and 30 - - Over 30 - - - - - Residence - - Street: - - City: - - Country: - - - - - Hobbies - - - - books - - sports - - music - - travelling - - - - - Submit - - ); -}; - -export default { - title: 'Accessibility Scenarios / Personal form accordion', - id: 'accordion1-accessibility-scenario', -}; diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Accordion2.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/AccordionFaq.stories.tsx similarity index 98% rename from packages/react-components/react-components/src/AccessibilityScenarios/Accordion2.stories.tsx rename to packages/react-components/react-components/src/AccessibilityScenarios/AccordionFaq.stories.tsx index 316895f3566e6f..ca26495eac9011 100644 --- a/packages/react-components/react-components/src/AccessibilityScenarios/Accordion2.stories.tsx +++ b/packages/react-components/react-components/src/AccessibilityScenarios/AccordionFaq.stories.tsx @@ -56,5 +56,5 @@ export const FAQAccordionAccessibilityScenario: React.FunctionComponent = () => export default { title: 'Accessibility Scenarios / FAQ accordion', - id: 'accordion2-accessibility-scenario', + id: 'accordion-faq-accessibility-scenario', }; diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Button.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/Button.stories.tsx index 1fd1b9b5bc4c5b..0e4fb3194bd576 100644 --- a/packages/react-components/react-components/src/AccessibilityScenarios/Button.stories.tsx +++ b/packages/react-components/react-components/src/AccessibilityScenarios/Button.stories.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Button } from '@fluentui/react-button'; + import { Scenario } from './utils'; export const MessengerButtonsAccessibilityScenario: React.FunctionComponent = () => { diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Input.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/Input.stories.tsx new file mode 100644 index 00000000000000..255e7e66e6a2d6 --- /dev/null +++ b/packages/react-components/react-components/src/AccessibilityScenarios/Input.stories.tsx @@ -0,0 +1,330 @@ +import * as React from 'react'; + +import { Input } from '@fluentui/react-input'; +import { Label } from '@fluentui/react-label'; +import { Button } from '@fluentui/react-button'; + +import { Scenario } from './utils'; + +import { useForm, Controller, OnSubmit } from 'react-hook-form'; +import { usePubSub, PubSubProvider, Handler } from '@cactuslab/usepubsub'; + +const regexes = { + onlyNameChars: /^[A-Za-zÀ-ÖØ-öø-ÿěščřžďťňůĚŠČŘŽĎŤŇŮ -]*$/, + // eslint-disable-next-line @fluentui/max-len + startsAndEndsWithLetter: /^(([A-Za-zÀ-ÖØ-öø-ÿěščřžďťňůĚŠČŘŽĎŤŇŮ][A-Za-zÀ-ÖØ-öø-ÿěščřžďťňůĚŠČŘŽĎŤŇŮ -]*[A-Za-zÀ-ÖØ-öø-ÿěščřžďťňůĚŠČŘŽĎŤŇŮ])|[A-Za-zÀ-ÖØ-öø-ÿěščřžďťňůĚŠČŘŽĎŤŇŮ])?$/, + noWhitespace: /^\S*$/, + hasNumber: /^\S*[0-9]\S*$/, + hasLowercaseLetter: /^\S*[a-z]\S*$/, + hasUppercaseLetter: /^\S*[A-Z]\S*$/, + hasSpecialChar: /^\S*[^0-9a-zA-ZÀ-ÖØ-öø-ÿěščřžďťňůĚŠČŘŽĎŤŇŮ\s]\S*$/, +}; + +interface FormInputs { + fullName: string; + nickname: string; + password: string; +} + +interface FormValidation { + subscribe: (channel: string, handler: Handler) => () => void; + unsubscribe: (channel: string, handler: Handler) => void; +} + +interface ValidationMessageProps { + id: string; + formValidation: FormValidation; +} +const ValidationMessage: React.FC = ({ id, formValidation, children }) => { + const [isAlerting, setIsAlerting] = React.useState(true); + + const alert = React.useCallback(() => { + setIsAlerting(false); + setTimeout(() => setIsAlerting(true), 200); + }, [setIsAlerting]); + + React.useEffect(() => { + formValidation.subscribe(id, alert); + return () => formValidation.unsubscribe(id, alert); + }, [formValidation, alert, id]); + return ( + + {children} + + ); +}; + +const useFormValidation = ( + handleSubmit: (callback: OnSubmit) => (e?: React.BaseSyntheticEvent) => Promise, +) => { + const pubSub = usePubSub(); + const isSubmitting = React.useRef(false); + + const wrappedHandleSubmit = React.useCallback( + (callback: OnSubmit) => { + const handler = handleSubmit(callback); + return async (e: React.BaseSyntheticEvent) => { + isSubmitting.current = true; + const result = await handler(e); + isSubmitting.current = false; + return result; + }; + }, + [isSubmitting, handleSubmit], + ); + + const onFieldValidated = React.useCallback( + (field: string) => { + if (!isSubmitting.current) { + pubSub.publish(field, 'validate'); + } + return true; + }, + [isSubmitting, pubSub], + ); + + const notifyFormFieldError = React.useCallback( + (field: string) => { + pubSub.publish(field, 'validate'); + return true; + }, + [pubSub], + ); + + return { + subscribe: pubSub.subscribe, + unsubscribe: pubSub.unsubscribe, + onFieldValidated, + handleSubmit: wrappedHandleSubmit, + notifyFormFieldError, + }; +}; + +const RegistrationFormInputsAccessibility = () => { + const { control, handleSubmit, errors, formState } = useForm({ + validateCriteriaMode: 'all', + mode: 'onBlur', + reValidateMode: 'onBlur', + }); + + const formValidation = useFormValidation(handleSubmit); + + const [isPasswordVisible, setIsPasswordVisible] = React.useState(false); + const [isSubmittedAndValid, setIsSubmittedAndValid] = React.useState(false); + + React.useEffect(() => { + // If the form is submitted and has errors, focus the first error fiel, otherwise do nothing + if (!formState.isSubmitting || formState.isValid) { + return; + } + const firstErrorName = Object.keys(errors)[0] as keyof FormInputs; + const firstErrorField = document.getElementById(firstErrorName); + + setTimeout(() => formValidation.notifyFormFieldError(firstErrorName), 200); + + if (firstErrorField) { + firstErrorField.focus(); + } + }, [errors, formState, formValidation]); + + React.useEffect(() => { + if (isSubmittedAndValid) { + document.getElementById('validMessage')?.focus(); + } + }, [isSubmittedAndValid]); + + const onSubmit = (data: FormInputs, event?: React.BaseSyntheticEvent) => { + event?.preventDefault(); + if (formState.isValid) { + setIsSubmittedAndValid(true); + } + }; + + const onShowPasswordChange = (event: React.ChangeEvent) => { + setIsPasswordVisible(!isPasswordVisible); + }; + + return ( + + Registration form + {!isSubmittedAndValid ? ( + + Full name: + + } + rules={{ + required: true, + minLength: 2, + maxLength: 50, + validate: { + onlyNameChars: value => regexes.onlyNameChars.test(value), + startsAndEndsWithLetter: value => regexes.startsAndEndsWithLetter.test(value), + always: () => { + if (!formState.isSubmitting) { + formValidation.onFieldValidated('fullName'); + } + return true; + }, + }, + }} + /> + {errors.fullName?.types && ( + + {'required' in errors.fullName.types ? ( + Full name is required. + ) : ( + <> + Full name is invalid. It must: + + {('minLength' in errors.fullName.types || 'maxLength' in errors.fullName.types) && ( + Have between 2 and 50 characters. + )} + {'onlyNameChars' in errors.fullName.types && ( + Contain only lowercase or uppercase letters, spaces or hyphens. + )} + {'startsAndEndsWithLetter' in errors.fullName.types && Start and end wit letter.} + + > + )} + + )} + + Nickname: + + } + rules={{ + minLength: 2, + maxLength: 20, + validate: { + onlyNameChars: value => regexes.onlyNameChars.test(value), + startsAndEndsWithLetter: value => regexes.startsAndEndsWithLetter.test(value), + always: () => { + if (!formState.isSubmitting) { + formValidation.onFieldValidated('nickname'); + } + return true; + }, + }, + }} + /> + {errors.nickname?.types && ( + + Nickname is invalid. It must: + + {('minLength' in errors.nickname.types || 'maxLength' in errors.nickname.types) && ( + Have between 2 and 20 characters. + )} + {'onlyNameChars' in errors.nickname.types && ( + Contain only lowercase or uppercase letters, spaces or hyphens. + )} + {'startsAndEndsWithLetter' in errors.nickname.types && Start and end wit letter.} + + + )} + + Password: + + } + rules={{ + required: true, + minLength: 8, + maxLength: 20, + validate: { + hasLowercaseLetter: value => regexes.hasLowercaseLetter.test(value), + hasUppercaseLetter: value => regexes.hasUppercaseLetter.test(value), + hasNumber: value => regexes.hasNumber.test(value), + hasSpecialChar: value => regexes.hasSpecialChar.test(value), + noWhitespace: value => regexes.noWhitespace.test(value), + always: () => { + if (!formState.isSubmitting) { + formValidation.onFieldValidated('password'); + } + return true; + }, + }, + }} + /> + + Show password + + + {errors.password?.types && ( + + {'required' in errors.password.types ? ( + Password is required. + ) : ( + <> + Password is invalid. It must: + + {('minLength' in errors.password.types || 'maxLength' in errors.password.types) && ( + Have between 8 and 20 characters. + )} + {('hasLowercaseLetter' in errors.password.types || + 'hasUppercaseLetter' in errors.password.types || + 'hasSpecialChar' in errors.password.types || + 'hasNumber' in errors.password.types || + 'noWhiteSpace' in errors.password.types) && ( + + Contain at least one lower case letter, upper case letter, number, special character and no + spaces. + + )} + + > + )} + + )} + + Register + + ) : ( + + The form is valid and would have been submitted. + + )} + + ); +}; + +export const RegistrationFormInputsAccessibilityScenario = () => ( + + + +); + +export default { + title: 'Accessibility Scenarios/ Registration form inputs', + id: 'input-accessibility-scenario', +}; diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/ListOfScenarios.stories.mdx b/packages/react-components/react-components/src/AccessibilityScenarios/ListOfScenarios.stories.mdx index c379b4565f4e6b..0d751da9efcefe 100644 --- a/packages/react-components/react-components/src/AccessibilityScenarios/ListOfScenarios.stories.mdx +++ b/packages/react-components/react-components/src/AccessibilityScenarios/ListOfScenarios.stories.mdx @@ -9,27 +9,31 @@ Accessibility scenarios are used to validate accessibility of components and dem ## Component: Accordion -- -- +- +- ## Component: Button - +## Component: Input + +- + ## Component: Link - ## Component: Menu -- +- - @@ -37,6 +41,14 @@ Accessibility scenarios are used to validate accessibility of components and dem - +## Component: Slider + +- + ## Component: SplitButton - { export default { title: 'Accessibility Scenarios / Profile menu', - id: 'menu1-accessibility-scenario', + id: 'menu-accessibility-scenario', }; diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Menu2.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/MenuSplitGroup.stories.tsx similarity index 96% rename from packages/react-components/react-components/src/AccessibilityScenarios/Menu2.stories.tsx rename to packages/react-components/react-components/src/AccessibilityScenarios/MenuSplitGroup.stories.tsx index ca4059b1ce9e29..33e1489d1c9868 100644 --- a/packages/react-components/react-components/src/AccessibilityScenarios/Menu2.stories.tsx +++ b/packages/react-components/react-components/src/AccessibilityScenarios/MenuSplitGroup.stories.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { MenuButton } from '@fluentui/react-button'; - import { Menu, MenuTrigger, MenuList, MenuPopover, MenuItem, MenuSplitGroup } from '@fluentui/react-menu'; import { Scenario } from './utils'; @@ -43,5 +42,5 @@ export const MenuWithSplitItemAccessibilityScenario: React.FunctionComponent = ( export default { title: 'Accessibility Scenarios / Menu with split item', - id: 'menu2-accessibility-scenario', + id: 'menu-splitgroup-accessibility-scenario', }; diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Popover.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/Popover.stories.tsx index 1aa9c0b9d7759b..833e8436bf0b68 100644 --- a/packages/react-components/react-components/src/AccessibilityScenarios/Popover.stories.tsx +++ b/packages/react-components/react-components/src/AccessibilityScenarios/Popover.stories.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Button } from '@fluentui/react-button'; - import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-popover'; import { Scenario } from './utils'; diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Slider.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/Slider.stories.tsx new file mode 100644 index 00000000000000..4b26688236a9be --- /dev/null +++ b/packages/react-components/react-components/src/AccessibilityScenarios/Slider.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import { Slider } from '@fluentui/react-slider'; +import { Label } from '@fluentui/react-label'; + +import { Scenario } from './utils'; + +export const SoundControlSlidersAccessibilityScenario: React.FunctionComponent = () => { + return ( + + Sound control panel + + Volume: + + + Bass: + + + Treble: + + + ); +}; + +export default { + title: 'Accessibility Scenarios/ Sound control sliders', + id: 'slider-accessibility-scenario', +}; diff --git a/packages/react-components/react-components/src/AccessibilityScenarios/Tooltip.stories.tsx b/packages/react-components/react-components/src/AccessibilityScenarios/Tooltip.stories.tsx index e02c119eb56aaf..5c28ad4c9789cd 100644 --- a/packages/react-components/react-components/src/AccessibilityScenarios/Tooltip.stories.tsx +++ b/packages/react-components/react-components/src/AccessibilityScenarios/Tooltip.stories.tsx @@ -2,35 +2,15 @@ import * as React from 'react'; import { Button } from '@fluentui/react-button'; -import { Label } from '@fluentui/react-label'; - -import { Tooltip, TooltipProps } from '@fluentui/react-tooltip'; +import { Tooltip } from '@fluentui/react-tooltip'; import { TextItalic24Regular, TextUnderline24Regular, TextBold24Regular } from '@fluentui/react-icons'; import { Scenario } from './utils'; export const ButtonsWithTooltipAccessibilityScenario: React.FunctionComponent = () => { - const [tooltipVisible, setTooltipVisible] = React.useState(false); - - const onVisibleChange: TooltipProps['onVisibleChange'] = (event, { visible }) => { - setTooltipVisible(visible); - }; - return ( - Tooltip as a password requirements - Password - - setTooltipVisible(visible => !visible)}>Password requirements - - - Tooltips for text formatting icon-only buttons } /> diff --git a/yarn.lock b/yarn.lock index 2f046721936d8e..c5ea8e478ec09a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1325,6 +1325,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@cactuslab/usepubsub@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@cactuslab/usepubsub/-/usepubsub-1.0.2.tgz#08877905242d2e1b6053ffc6d0efbb097dd5a32b" + integrity sha512-xNl3YemJ+aTDgs7MRlmGla7TGDVowtZ/FAF5DaHfR5EJaM4tV5EvK0FIk9hz/lt3AHXL+vxnBoyf4z2Z1sYzJA== + "@charlietango/use-client-hydrated@^1.8.2": version "1.8.2" resolved "https://registry.yarnpkg.com/@charlietango/use-client-hydrated/-/use-client-hydrated-1.8.2.tgz#7066f4c9966233dcb3dcc755351570e62b101a4f"
Full name is required.
Full name is invalid. It must:
Nickname is invalid. It must:
Password is required.
Password is invalid. It must:
+ The form is valid and would have been submitted. +