Skip to content

Commit 5c415ba

Browse files
authored
Merge pull request #59 from mayank-patel/feat/accessibility-support
feat: add accessibility support (WCAG 2.1/2.2 AA, Section 508, VoiceOver, TalkBack)
2 parents 04695ab + 0e5c40c commit 5c415ba

3 files changed

Lines changed: 323 additions & 11 deletions

File tree

README.md

Lines changed: 145 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,19 @@ ref.current?.clear();
7070

7171
`FloatingLabel` accepts all standard [`TextInput`](https://reactnative.dev/docs/textinput) props plus the following:
7272

73-
| Prop | Type | Default | Description |
74-
| ----------------- | ------------------------- | ------- | ------------------------------------------------------------ |
75-
| `children` | `ReactNode` || The floating label text |
76-
| `style` | `ViewStyle` || Style for the outer container `View` |
77-
| `inputStyle` | `TextInputProps['style']` || Style applied to the inner `TextInput` |
78-
| `labelStyle` | `TextStyle` || Style applied to the animated label |
79-
| `disabled` | `boolean` | `false` | Disables the input (`editable={false}`) |
80-
| `value` | `string` || Controlled value; animates the label when changed externally |
81-
| `secureTextEntry` | `boolean` | `false` | Hides input text (password field) |
82-
| `password` | `boolean` || **Deprecated** — use `secureTextEntry` instead |
83-
| `myRef` | `React.Ref<TextInput>` || **Deprecated** — use the standard `ref` prop instead |
73+
| Prop | Type | Default | Description |
74+
| ----------------- | ------------------------- | ------- | ----------------------------------------------------------------------------------------------- |
75+
| `children` | `ReactNode` || The floating label text |
76+
| `style` | `ViewStyle` || Style for the outer container `View` |
77+
| `inputStyle` | `TextInputProps['style']` || Style applied to the inner `TextInput` |
78+
| `labelStyle` | `TextStyle` || Style applied to the animated label |
79+
| `disabled` | `boolean` | `false` | Disables the input (`editable={false}`); also sets `accessibilityState.disabled` automatically |
80+
| `value` | `string` || Controlled value; animates the label when changed externally |
81+
| `secureTextEntry` | `boolean` | `false` | Hides input text (password field); also sets `textContentType` and `autoComplete` automatically |
82+
| `errorMessage` | `string` || Error text rendered below the input and announced by screen readers |
83+
| `helperText` | `string` || Helper text rendered below the input and announced by screen readers |
84+
| `password` | `boolean` || **Deprecated** — use `secureTextEntry` instead |
85+
| `myRef` | `React.Ref<TextInput>` || **Deprecated** — use the standard `ref` prop instead |
8486

8587
### `FloatingLabelHandle` (ref)
8688

@@ -92,6 +94,138 @@ ref.current?.clear();
9294

9395
---
9496

97+
## Accessibility
98+
99+
`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.
100+
101+
### Label association (accessibilityLabel)
102+
103+
The component automatically derives `accessibilityLabel` from the `children` string so screen readers announce the label when the input is focused.
104+
105+
```tsx
106+
// Screen readers announce "Email" when this input receives focus
107+
<FloatingLabel>Email</FloatingLabel>
108+
```
109+
110+
Override it by passing `accessibilityLabel` explicitly:
111+
112+
```tsx
113+
<FloatingLabel accessibilityLabel="Your email address">Email</FloatingLabel>
114+
```
115+
116+
On web (react-native-web), `accessibilityLabel` is mapped to `aria-label` automatically.
117+
118+
### Disabled state
119+
120+
When `disabled={true}` is passed, the component automatically sets `accessibilityState={{ disabled: true }}` on the input so screen readers announce it as unavailable.
121+
122+
```tsx
123+
<FloatingLabel disabled>Email</FloatingLabel>
124+
```
125+
126+
Override or extend `accessibilityState` as needed — your values take precedence:
127+
128+
```tsx
129+
<FloatingLabel disabled accessibilityState={{disabled: false, selected: true}}>
130+
Email
131+
</FloatingLabel>
132+
```
133+
134+
### Password fields
135+
136+
When `secureTextEntry={true}` is passed, the component automatically sets:
137+
138+
- `textContentType="password"` (iOS) — enables password autofill and correct VoiceOver announcement
139+
- `autoComplete="password"` (Android/web) — enables password autofill
140+
141+
```tsx
142+
<FloatingLabel secureTextEntry>Password</FloatingLabel>
143+
```
144+
145+
Override when you need a more specific value (e.g. for a new password field):
146+
147+
```tsx
148+
<FloatingLabel secureTextEntry textContentType="newPassword" autoComplete="new-password">
149+
New Password
150+
</FloatingLabel>
151+
```
152+
153+
Add an `accessibilityHint` to give users extra context:
154+
155+
```tsx
156+
<FloatingLabel secureTextEntry accessibilityHint="Must be at least 8 characters">
157+
Password
158+
</FloatingLabel>
159+
```
160+
161+
### Error messages
162+
163+
Pass `errorMessage` to render error text below the input. It is announced by screen readers whenever the value changes (via `accessibilityLiveRegion="polite"`).
164+
165+
```tsx
166+
const [error, setError] = React.useState('');
167+
168+
<FloatingLabel value={email} onChangeText={setEmail} errorMessage={error}>
169+
Email
170+
</FloatingLabel>;
171+
```
172+
173+
### Helper text
174+
175+
Pass `helperText` to render supplementary guidance below the input. Like `errorMessage`, it uses `accessibilityLiveRegion="polite"` for dynamic announcements.
176+
177+
```tsx
178+
<FloatingLabel helperText="We'll never share your email">Email</FloatingLabel>
179+
```
180+
181+
### Focus indicators
182+
183+
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`:
184+
185+
```tsx
186+
const [focused, setFocused] = React.useState(false);
187+
188+
<FloatingLabel
189+
inputStyle={focused ? {borderColor: '#0057b8', borderWidth: 2} : undefined}
190+
onFocus={() => setFocused(true)}
191+
onBlur={() => setFocused(false)}
192+
>
193+
Email
194+
</FloatingLabel>;
195+
```
196+
197+
WCAG 2.2 requires the focus indicator to have a contrast ratio of at least 3:1 against adjacent colors.
198+
199+
### Touch target size
200+
201+
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:
202+
203+
```tsx
204+
<FloatingLabel style={{minHeight: 44}}>Email</FloatingLabel>
205+
```
206+
207+
### Screen reader testing checklist
208+
209+
Before shipping, verify the following manually with screen readers enabled:
210+
211+
**iOS — VoiceOver** (`Settings → Accessibility → VoiceOver`):
212+
213+
- [ ] Focusing the input announces the label text
214+
- [ ] A disabled input is announced as "dimmed" or "unavailable"
215+
- [ ] A password input is announced as a "secure text field"
216+
- [ ] Error messages are announced when they appear
217+
- [ ] Helper text is readable when navigating to the input
218+
219+
**Android — TalkBack** (`Settings → Accessibility → TalkBack`):
220+
221+
- [ ] Focusing the input announces the label text
222+
- [ ] A disabled input is announced as "disabled"
223+
- [ ] A password input is announced as a password field
224+
- [ ] Error messages are announced when they appear
225+
- [ ] Helper text is readable when navigating to the input
226+
227+
---
228+
95229
## Testing locally with a React Native app
96230

97231
The quickest way to test the library end-to-end against a real device or simulator before publishing.

__tests__/index.test.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,130 @@ describe('FloatingLabel — props & controlled value', () => {
209209
timingSpy.mockRestore();
210210
});
211211
});
212+
213+
describe('FloatingLabel — accessibility: accessibilityLabel', () => {
214+
it('derives accessibilityLabel from string children when not explicitly provided', () => {
215+
render(<FloatingLabel>Email</FloatingLabel>);
216+
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBe('Email');
217+
});
218+
219+
it('uses explicit accessibilityLabel over derived value', () => {
220+
render(<FloatingLabel accessibilityLabel="Your email address">Email</FloatingLabel>);
221+
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBe('Your email address');
222+
});
223+
224+
it('does not derive accessibilityLabel from non-string children', () => {
225+
render(
226+
<FloatingLabel>
227+
<>{/* jsx child */}</>
228+
</FloatingLabel>,
229+
);
230+
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBeUndefined();
231+
});
232+
});
233+
234+
describe('FloatingLabel — accessibility: disabled accessibilityState', () => {
235+
it('sets accessibilityState.disabled=true when disabled={true}', () => {
236+
render(<FloatingLabel disabled>Email</FloatingLabel>);
237+
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({
238+
disabled: true,
239+
});
240+
});
241+
242+
it('sets accessibilityState.disabled=false when disabled={false}', () => {
243+
render(<FloatingLabel disabled={false}>Email</FloatingLabel>);
244+
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({
245+
disabled: false,
246+
});
247+
});
248+
249+
it('merges consumer accessibilityState with auto-set disabled', () => {
250+
render(
251+
<FloatingLabel disabled accessibilityState={{selected: true}}>
252+
Email
253+
</FloatingLabel>,
254+
);
255+
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({
256+
disabled: true,
257+
selected: true,
258+
});
259+
});
260+
261+
it('consumer accessibilityState.disabled overrides auto-set value', () => {
262+
render(
263+
<FloatingLabel disabled accessibilityState={{disabled: false}}>
264+
Email
265+
</FloatingLabel>,
266+
);
267+
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({
268+
disabled: false,
269+
});
270+
});
271+
272+
it('does not set accessibilityState when disabled is not provided', () => {
273+
render(<FloatingLabel>Email</FloatingLabel>);
274+
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toBeUndefined();
275+
});
276+
});
277+
278+
describe('FloatingLabel — accessibility: password field defaults', () => {
279+
it('sets textContentType and autoComplete to "password" when secureTextEntry={true}', () => {
280+
render(<FloatingLabel secureTextEntry>Password</FloatingLabel>);
281+
const input = screen.UNSAFE_getByType(TextInput);
282+
expect(input.props.textContentType).toBe('password');
283+
expect(input.props.autoComplete).toBe('password');
284+
});
285+
286+
it('explicit textContentType overrides the default', () => {
287+
render(
288+
<FloatingLabel secureTextEntry textContentType="newPassword">
289+
Password
290+
</FloatingLabel>,
291+
);
292+
expect(screen.UNSAFE_getByType(TextInput).props.textContentType).toBe('newPassword');
293+
});
294+
295+
it('explicit autoComplete overrides the default', () => {
296+
render(
297+
<FloatingLabel secureTextEntry autoComplete="new-password">
298+
Password
299+
</FloatingLabel>,
300+
);
301+
expect(screen.UNSAFE_getByType(TextInput).props.autoComplete).toBe('new-password');
302+
});
303+
304+
it('does not inject textContentType or autoComplete without secureTextEntry', () => {
305+
render(<FloatingLabel>Email</FloatingLabel>);
306+
const input = screen.UNSAFE_getByType(TextInput);
307+
expect(input.props.textContentType).toBeUndefined();
308+
expect(input.props.autoComplete).toBeUndefined();
309+
});
310+
});
311+
312+
describe('FloatingLabel — accessibility: errorMessage and helperText', () => {
313+
it('renders errorMessage text when provided', () => {
314+
render(<FloatingLabel errorMessage="Email is required">Email</FloatingLabel>);
315+
expect(screen.getByText('Email is required')).toBeTruthy();
316+
});
317+
318+
it('error text node has accessibilityLiveRegion="polite"', () => {
319+
render(<FloatingLabel errorMessage="Email is required">Email</FloatingLabel>);
320+
expect(screen.getByText('Email is required').props.accessibilityLiveRegion).toBe('polite');
321+
});
322+
323+
it('renders helperText when provided', () => {
324+
render(<FloatingLabel helperText="Enter your work email">Email</FloatingLabel>);
325+
expect(screen.getByText('Enter your work email')).toBeTruthy();
326+
});
327+
328+
it('helper text node has accessibilityLiveRegion="polite"', () => {
329+
render(<FloatingLabel helperText="Enter your work email">Email</FloatingLabel>);
330+
expect(screen.getByText('Enter your work email').props.accessibilityLiveRegion).toBe('polite');
331+
});
332+
333+
it('renders no extra text nodes when neither errorMessage nor helperText is provided', () => {
334+
render(<FloatingLabel>Email</FloatingLabel>);
335+
// Only the label text 'Email' should be present
336+
expect(screen.getAllByText('Email')).toHaveLength(1);
337+
});
338+
});

0 commit comments

Comments
 (0)