diff --git a/README.md b/README.md index d3e4514..49c0fda 100644 --- a/README.md +++ b/README.md @@ -70,17 +70,19 @@ ref.current?.clear(); `FloatingLabel` accepts all standard [`TextInput`](https://reactnative.dev/docs/textinput) props plus the following: -| Prop | Type | Default | Description | -| ----------------- | ------------------------- | ------- | ------------------------------------------------------------ | -| `children` | `ReactNode` | — | The floating label text | -| `style` | `ViewStyle` | — | Style for the outer container `View` | -| `inputStyle` | `TextInputProps['style']` | — | Style applied to the inner `TextInput` | -| `labelStyle` | `TextStyle` | — | Style applied to the animated label | -| `disabled` | `boolean` | `false` | Disables the input (`editable={false}`) | -| `value` | `string` | — | Controlled value; animates the label when changed externally | -| `secureTextEntry` | `boolean` | `false` | Hides input text (password field) | -| `password` | `boolean` | — | **Deprecated** — use `secureTextEntry` instead | -| `myRef` | `React.Ref` | — | **Deprecated** — use the standard `ref` prop instead | +| Prop | Type | Default | Description | +| ----------------- | ------------------------- | ------- | ----------------------------------------------------------------------------------------------- | +| `children` | `ReactNode` | — | The floating label text | +| `style` | `ViewStyle` | — | Style for the outer container `View` | +| `inputStyle` | `TextInputProps['style']` | — | Style applied to the inner `TextInput` | +| `labelStyle` | `TextStyle` | — | Style applied to the animated label | +| `disabled` | `boolean` | `false` | Disables the input (`editable={false}`); also sets `accessibilityState.disabled` automatically | +| `value` | `string` | — | Controlled value; animates the label when changed externally | +| `secureTextEntry` | `boolean` | `false` | Hides input text (password field); also sets `textContentType` and `autoComplete` automatically | +| `errorMessage` | `string` | — | Error text rendered below the input and announced by screen readers | +| `helperText` | `string` | — | Helper text rendered below the input and announced by screen readers | +| `password` | `boolean` | — | **Deprecated** — use `secureTextEntry` instead | +| `myRef` | `React.Ref` | — | **Deprecated** — use the standard `ref` prop instead | ### `FloatingLabelHandle` (ref) @@ -92,6 +94,138 @@ ref.current?.clear(); --- +## Accessibility + +`react-native-floating-labels` is designed to work correctly with screen readers (VoiceOver on iOS, TalkBack on Android) and meets WCAG 2.1/2.2 AA, Section 508, and the React Native accessibility model out of the box — with no extra configuration required for common cases. + +### Label association (accessibilityLabel) + +The component automatically derives `accessibilityLabel` from the `children` string so screen readers announce the label when the input is focused. + +```tsx +// Screen readers announce "Email" when this input receives focus +Email +``` + +Override it by passing `accessibilityLabel` explicitly: + +```tsx +Email +``` + +On web (react-native-web), `accessibilityLabel` is mapped to `aria-label` automatically. + +### Disabled state + +When `disabled={true}` is passed, the component automatically sets `accessibilityState={{ disabled: true }}` on the input so screen readers announce it as unavailable. + +```tsx +Email +``` + +Override or extend `accessibilityState` as needed — your values take precedence: + +```tsx + + Email + +``` + +### Password fields + +When `secureTextEntry={true}` is passed, the component automatically sets: + +- `textContentType="password"` (iOS) — enables password autofill and correct VoiceOver announcement +- `autoComplete="password"` (Android/web) — enables password autofill + +```tsx +Password +``` + +Override when you need a more specific value (e.g. for a new password field): + +```tsx + + New Password + +``` + +Add an `accessibilityHint` to give users extra context: + +```tsx + + Password + +``` + +### Error messages + +Pass `errorMessage` to render error text below the input. It is announced by screen readers whenever the value changes (via `accessibilityLiveRegion="polite"`). + +```tsx +const [error, setError] = React.useState(''); + + + Email +; +``` + +### Helper text + +Pass `helperText` to render supplementary guidance below the input. Like `errorMessage`, it uses `accessibilityLiveRegion="polite"` for dynamic announcements. + +```tsx +Email +``` + +### Focus indicators + +The component does not enforce focus styles — this keeps it flexible for any design system. To implement a WCAG 2.2-compliant visible focus indicator, use `onFocus`/`onBlur` with `inputStyle`: + +```tsx +const [focused, setFocused] = React.useState(false); + + setFocused(true)} + onBlur={() => setFocused(false)} +> + Email +; +``` + +WCAG 2.2 requires the focus indicator to have a contrast ratio of at least 3:1 against adjacent colors. + +### Touch target size + +Apple and Google both recommend a minimum touch target of **44×44dp**. The component's default height is 40dp. To meet the recommendation, set `style` on the container: + +```tsx +Email +``` + +### Screen reader testing checklist + +Before shipping, verify the following manually with screen readers enabled: + +**iOS — VoiceOver** (`Settings → Accessibility → VoiceOver`): + +- [ ] Focusing the input announces the label text +- [ ] A disabled input is announced as "dimmed" or "unavailable" +- [ ] A password input is announced as a "secure text field" +- [ ] Error messages are announced when they appear +- [ ] Helper text is readable when navigating to the input + +**Android — TalkBack** (`Settings → Accessibility → TalkBack`): + +- [ ] Focusing the input announces the label text +- [ ] A disabled input is announced as "disabled" +- [ ] A password input is announced as a password field +- [ ] Error messages are announced when they appear +- [ ] Helper text is readable when navigating to the input + +--- + ## Testing locally with a React Native app The quickest way to test the library end-to-end against a real device or simulator before publishing. diff --git a/__tests__/index.test.tsx b/__tests__/index.test.tsx index 5c659be..67c8e86 100644 --- a/__tests__/index.test.tsx +++ b/__tests__/index.test.tsx @@ -209,3 +209,130 @@ describe('FloatingLabel — props & controlled value', () => { timingSpy.mockRestore(); }); }); + +describe('FloatingLabel — accessibility: accessibilityLabel', () => { + it('derives accessibilityLabel from string children when not explicitly provided', () => { + render(Email); + expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBe('Email'); + }); + + it('uses explicit accessibilityLabel over derived value', () => { + render(Email); + expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBe('Your email address'); + }); + + it('does not derive accessibilityLabel from non-string children', () => { + render( + + <>{/* jsx child */} + , + ); + expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBeUndefined(); + }); +}); + +describe('FloatingLabel — accessibility: disabled accessibilityState', () => { + it('sets accessibilityState.disabled=true when disabled={true}', () => { + render(Email); + expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({ + disabled: true, + }); + }); + + it('sets accessibilityState.disabled=false when disabled={false}', () => { + render(Email); + expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({ + disabled: false, + }); + }); + + it('merges consumer accessibilityState with auto-set disabled', () => { + render( + + Email + , + ); + expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({ + disabled: true, + selected: true, + }); + }); + + it('consumer accessibilityState.disabled overrides auto-set value', () => { + render( + + Email + , + ); + expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({ + disabled: false, + }); + }); + + it('does not set accessibilityState when disabled is not provided', () => { + render(Email); + expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toBeUndefined(); + }); +}); + +describe('FloatingLabel — accessibility: password field defaults', () => { + it('sets textContentType and autoComplete to "password" when secureTextEntry={true}', () => { + render(Password); + const input = screen.UNSAFE_getByType(TextInput); + expect(input.props.textContentType).toBe('password'); + expect(input.props.autoComplete).toBe('password'); + }); + + it('explicit textContentType overrides the default', () => { + render( + + Password + , + ); + expect(screen.UNSAFE_getByType(TextInput).props.textContentType).toBe('newPassword'); + }); + + it('explicit autoComplete overrides the default', () => { + render( + + Password + , + ); + expect(screen.UNSAFE_getByType(TextInput).props.autoComplete).toBe('new-password'); + }); + + it('does not inject textContentType or autoComplete without secureTextEntry', () => { + render(Email); + const input = screen.UNSAFE_getByType(TextInput); + expect(input.props.textContentType).toBeUndefined(); + expect(input.props.autoComplete).toBeUndefined(); + }); +}); + +describe('FloatingLabel — accessibility: errorMessage and helperText', () => { + it('renders errorMessage text when provided', () => { + render(Email); + expect(screen.getByText('Email is required')).toBeTruthy(); + }); + + it('error text node has accessibilityLiveRegion="polite"', () => { + render(Email); + expect(screen.getByText('Email is required').props.accessibilityLiveRegion).toBe('polite'); + }); + + it('renders helperText when provided', () => { + render(Email); + expect(screen.getByText('Enter your work email')).toBeTruthy(); + }); + + it('helper text node has accessibilityLiveRegion="polite"', () => { + render(Email); + expect(screen.getByText('Enter your work email').props.accessibilityLiveRegion).toBe('polite'); + }); + + it('renders no extra text nodes when neither errorMessage nor helperText is provided', () => { + render(Email); + // Only the label text 'Email' should be present + expect(screen.getAllByText('Email')).toHaveLength(1); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index f911212..3e15c6f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,7 @@ import { Animated, Platform, StyleSheet, + Text, TextInput, TextInputProps, TextStyle, @@ -22,6 +23,10 @@ export interface FloatingLabelHandle { export interface FloatingLabelProps extends Omit { children?: React.ReactNode; disabled?: boolean; + /** Error message rendered below the input and announced by screen readers. */ + errorMessage?: string; + /** Helper text rendered below the input and announced by screen readers. */ + helperText?: string; inputStyle?: TextInputProps['style']; labelStyle?: TextStyle; /** @deprecated Use the standard ref prop instead */ @@ -35,8 +40,13 @@ export interface FloatingLabelProps extends Omit { const FloatingLabel = React.forwardRef( ( { + accessibilityLabel, + accessibilityState, + autoComplete, children, disabled, + errorMessage, + helperText, inputStyle, labelStyle, myRef, @@ -48,6 +58,7 @@ const FloatingLabel = React.forwardRef( placeholder, secureTextEntry, style, + textContentType, value, ...restInputProps }, @@ -62,6 +73,20 @@ const FloatingLabel = React.forwardRef( const isFirstRender = useRef(true); const inputRef = useRef(null); + // Phase 1: derive accessibilityLabel from children when not explicitly provided + const derivedAccessibilityLabel = + accessibilityLabel ?? (typeof children === 'string' ? children : undefined); + + // Phase 1: merge disabled into accessibilityState + const derivedAccessibilityState = + disabled !== undefined ? {disabled, ...accessibilityState} : accessibilityState; + + // Phase 2: default textContentType and autoComplete for password fields + const derivedTextContentType = + textContentType ?? (secureTextEntry || password ? 'password' : undefined); + const derivedAutoComplete = + autoComplete ?? (secureTextEntry || password ? 'password' : undefined); + const animate = useCallback((isDirty: boolean) => { const target = isDirty ? dirtyStyle : cleanStyle; Animated.parallel( @@ -157,6 +182,9 @@ const FloatingLabel = React.forwardRef( {...restInputProps} ref={mergedRef} style={[styles.input, inputStyle]} + accessibilityLabel={derivedAccessibilityLabel} + accessibilityState={derivedAccessibilityState} + autoComplete={derivedAutoComplete} editable={!disabled} onBlur={handleBlur} onChangeText={handleChangeText} @@ -164,9 +192,20 @@ const FloatingLabel = React.forwardRef( onFocus={handleFocus} placeholder={placeholder} secureTextEntry={secureTextEntry || password} + textContentType={derivedTextContentType} underlineColorAndroid="transparent" value={text} /> + {errorMessage ? ( + + {errorMessage} + + ) : null} + {helperText ? ( + + {helperText} + + ) : null} ); }, @@ -194,6 +233,18 @@ const styles = StyleSheet.create({ color: '#AAA', position: 'absolute', } as TextStyle, + errorText: { + fontSize: 12, + color: '#cc0000', + marginTop: 4, + paddingLeft: 2, + }, + helperText: { + fontSize: 12, + color: '#666666', + marginTop: 4, + paddingLeft: 2, + }, }); FloatingLabel.displayName = 'FloatingLabel';