diff --git a/.changeset/tonic-ui-form-control.md b/.changeset/tonic-ui-form-control.md new file mode 100644 index 0000000000..43def8b35d --- /dev/null +++ b/.changeset/tonic-ui-form-control.md @@ -0,0 +1,5 @@ +--- +"@tonic-ui/react": minor +--- + +feat: Add `FormControl` components with comprehensive accessibility support diff --git a/packages/react-docs/config/sidebar-routes.js b/packages/react-docs/config/sidebar-routes.js index b3b9bec244..fd837de584 100644 --- a/packages/react-docs/config/sidebar-routes.js +++ b/packages/react-docs/config/sidebar-routes.js @@ -89,7 +89,6 @@ export const routes = [ { title: 'Getting started', path: 'experiments' }, { title: 'FORM CONTROLS', heading: true }, - { title: 'FormControl', path: 'experiments/form-control' }, { title: 'ButtonBox', path: 'experiments/button-box' }, { title: 'Dropdown', path: 'experiments/dropdown' }, { title: 'DropdownBase', path: 'experiments/dropdown-base' }, @@ -222,6 +221,7 @@ export const routes = [ }, }, { title: 'CheckboxGroup', path: 'components/checkbox-group' }, + { title: 'FormControl', path: 'components/form-control' }, { title: 'Input', path: 'components/input', diff --git a/packages/react-docs/experiments/form-control/FormCharacterCount.js b/packages/react-docs/experiments/form-control/FormCharacterCount.js deleted file mode 100644 index 05c3881f8e..0000000000 --- a/packages/react-docs/experiments/form-control/FormCharacterCount.js +++ /dev/null @@ -1,39 +0,0 @@ -import { Text, Flex } from '@tonic-ui/react'; -import { useId } from '@tonic-ui/react-hooks'; -import { ensureString } from 'ensure-type'; -import React, { forwardRef } from 'react'; -import useFormControl from './useFormControl'; -import { - useFormCharacterCountColors, - useFormCharacterCountStyle, -} from './styles'; - -const FormCharacterCount = forwardRef(( - { - value = '', - max = 0, - ...rest - }, - ref -) => { - const defaultId = useId(); - const { formCharacterCountId } = useFormControl() ?? {}; - const id = formCharacterCountId ?? defaultId; - const characterCount = ensureString(value).length; - const isOverLimit = characterCount > max; - const { lengthColor, maxColor } = useFormCharacterCountColors({ - isOverLimit, - }); - const styleProps = useFormCharacterCountStyle(); - - return ( - - {characterCount} - /{max} - - ); -}); - -FormCharacterCount.displayName = 'FormCharacterCount'; - -export default FormCharacterCount; diff --git a/packages/react-docs/experiments/form-control/FormControl.js b/packages/react-docs/experiments/form-control/FormControl.js deleted file mode 100644 index ceda9015c3..0000000000 --- a/packages/react-docs/experiments/form-control/FormControl.js +++ /dev/null @@ -1,55 +0,0 @@ -import { Stack } from '@tonic-ui/react'; -import { useId } from '@tonic-ui/react-hooks'; -import React, { forwardRef, useMemo } from 'react'; -import { FormControlContext } from './context'; -import { useFormControlStyle } from './styles'; - -const FormControl = forwardRef(( - { - children, - disabled = false, - error = false, - readOnly = false, - orientation = 'vertical', - ...rest - }, - ref -) => { - const styleProps = useFormControlStyle({ orientation }); - const formCharacterCountId = useId(); - const formErrorMessageId = useId(); - const formHelperTextId = useId(); - const formInputId = useId(); - - const contextValue = useMemo(() => ({ - disabled, - error, - readOnly, - formCharacterCountId, - formErrorMessageId, - formHelperTextId, - formInputId, - orientation, - }), [ - disabled, - error, - readOnly, - formCharacterCountId, - formErrorMessageId, - formHelperTextId, - formInputId, - orientation, - ]); - - return ( - - - {children} - - - ); -}); - -FormControl.displayName = 'FormControl'; - -export default FormControl; diff --git a/packages/react-docs/experiments/form-control/FormInput.js b/packages/react-docs/experiments/form-control/FormInput.js deleted file mode 100644 index d9bd4da30d..0000000000 --- a/packages/react-docs/experiments/form-control/FormInput.js +++ /dev/null @@ -1,47 +0,0 @@ -import { Input } from '@tonic-ui/react'; -import { useId } from '@tonic-ui/react-hooks'; -import { ariaAttr } from '@tonic-ui/utils'; -import React, { forwardRef } from 'react'; -import useFormControl from './useFormControl'; - -const FormInput = forwardRef((props, ref) => { - const defaultId = useId(); - const { - disabled, - error, - readOnly, - - // IDs for associated form control elements - formInputId, - formErrorMessageId, - formHelperTextId, - formCharacterCountId, - } = useFormControl() ?? {}; - const id = formInputId ?? defaultId; - const describedByIds = []; - if (formErrorMessageId) { - describedByIds.push(formErrorMessageId); - } - if (formHelperTextId) { - describedByIds.push(formHelperTextId); - } - if (formCharacterCountId) { - describedByIds.push(formCharacterCountId); - } - - return ( - - ); -}); - -FormInput.displayName = 'FormInput'; - -export default FormInput; diff --git a/packages/react-docs/experiments/form-control/index.js b/packages/react-docs/experiments/form-control/index.js deleted file mode 100644 index 5e9bd43b7d..0000000000 --- a/packages/react-docs/experiments/form-control/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import FormCharacterCount from './FormCharacterCount'; -import FormControl from './FormControl'; -import FormErrorMessage from './FormErrorMessage'; -import FormHelperText from './FormHelperText'; -import FormInput from './FormInput'; -import FormLabel from './FormLabel'; -import useFormControl from './useFormControl'; - -export { - FormCharacterCount, - FormControl, - FormErrorMessage, - FormHelperText, - FormInput, - FormLabel, - useFormControl, -}; diff --git a/packages/react-docs/pages/components/form-control/basic.js b/packages/react-docs/pages/components/form-control/basic.js new file mode 100644 index 0000000000..464722bd16 --- /dev/null +++ b/packages/react-docs/pages/components/form-control/basic.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { + FormControl, + FormLabel, + FormInput, + FormHelperText, +} from '@tonic-ui/react'; + +const App = () => { + return ( + + Username + + + Choose a unique username that others will see + + + ); +}; + +export default App; diff --git a/packages/react-docs/pages/components/form-control/character-count.js b/packages/react-docs/pages/components/form-control/character-count.js new file mode 100644 index 0000000000..4e0b87db3c --- /dev/null +++ b/packages/react-docs/pages/components/form-control/character-count.js @@ -0,0 +1,26 @@ +import React, { useState } from 'react'; +import { + FormControl, + FormLabel, + FormInput, + FormCharacterCount, +} from '@tonic-ui/react'; + +const App = () => { + const [bio, setBio] = useState(''); + const maxChars = 50; + + return ( + + Bio + setBio(e.target.value)} + /> + + + ); +}; + +export default App; diff --git a/packages/react-docs/pages/experiments/form-control/advanced-setup.js b/packages/react-docs/pages/components/form-control/complex-form.js similarity index 76% rename from packages/react-docs/pages/experiments/form-control/advanced-setup.js rename to packages/react-docs/pages/components/form-control/complex-form.js index 82960bb7d9..9bd8d7c8d1 100644 --- a/packages/react-docs/pages/experiments/form-control/advanced-setup.js +++ b/packages/react-docs/pages/components/form-control/complex-form.js @@ -1,18 +1,26 @@ import React, { useState } from 'react'; import { ensureArray } from 'ensure-type'; -import { Box, Stack, Button, Text } from '@tonic-ui/react'; import { + Box, + Stack, + Button, + Text, + Flex, FormControl, - FormInput, FormLabel, + FormInput, + FormTextarea, FormErrorMessage, FormHelperText, -} from '@/experiments/form-control'; + FormCharacterCount, +} from '@tonic-ui/react'; const App = () => { const [formData, setFormData] = useState({ email: '', password: '', + country: '', + bio: '', }); const [errors, setErrors] = useState({}); @@ -39,6 +47,19 @@ const App = () => { } return passwordErrors; } + case 'country': { + return !value ? 'Please select your experience level' : ''; + } + case 'bio': { + const bioErrors = []; + if (value.length < 10) { + bioErrors.push('Bio must be at least 10 characters'); + } + if (value.length > 200) { + bioErrors.push('Bio must not exceed 200 characters'); + } + return bioErrors; + } default: return ''; } @@ -135,6 +156,26 @@ const App = () => { + {/* Bio textarea with character validation */} + + Bio + + + + + Write a brief bio (10-200 characters) + + + + + {/* Submit Button */}