Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 145 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextInput>` | — | **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<TextInput>` | — | **Deprecated** — use the standard `ref` prop instead |

### `FloatingLabelHandle` (ref)

Expand All @@ -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
<FloatingLabel>Email</FloatingLabel>
```

Override it by passing `accessibilityLabel` explicitly:

```tsx
<FloatingLabel accessibilityLabel="Your email address">Email</FloatingLabel>
```

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
<FloatingLabel disabled>Email</FloatingLabel>
```

Override or extend `accessibilityState` as needed — your values take precedence:

```tsx
<FloatingLabel disabled accessibilityState={{disabled: false, selected: true}}>
Email
</FloatingLabel>
```

### 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
<FloatingLabel secureTextEntry>Password</FloatingLabel>
```

Override when you need a more specific value (e.g. for a new password field):

```tsx
<FloatingLabel secureTextEntry textContentType="newPassword" autoComplete="new-password">
New Password
</FloatingLabel>
```

Add an `accessibilityHint` to give users extra context:

```tsx
<FloatingLabel secureTextEntry accessibilityHint="Must be at least 8 characters">
Password
</FloatingLabel>
```

### 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('');

<FloatingLabel value={email} onChangeText={setEmail} errorMessage={error}>
Email
</FloatingLabel>;
```

### Helper text

Pass `helperText` to render supplementary guidance below the input. Like `errorMessage`, it uses `accessibilityLiveRegion="polite"` for dynamic announcements.

```tsx
<FloatingLabel helperText="We'll never share your email">Email</FloatingLabel>
```

### 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);

<FloatingLabel
inputStyle={focused ? {borderColor: '#0057b8', borderWidth: 2} : undefined}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
Email
</FloatingLabel>;
```

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
<FloatingLabel style={{minHeight: 44}}>Email</FloatingLabel>
```

### 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.
Expand Down
127 changes: 127 additions & 0 deletions __tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FloatingLabel>Email</FloatingLabel>);
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBe('Email');
});

it('uses explicit accessibilityLabel over derived value', () => {
render(<FloatingLabel accessibilityLabel="Your email address">Email</FloatingLabel>);
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBe('Your email address');
});

it('does not derive accessibilityLabel from non-string children', () => {
render(
<FloatingLabel>
<>{/* jsx child */}</>
</FloatingLabel>,
);
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityLabel).toBeUndefined();
});
});

describe('FloatingLabel — accessibility: disabled accessibilityState', () => {
it('sets accessibilityState.disabled=true when disabled={true}', () => {
render(<FloatingLabel disabled>Email</FloatingLabel>);
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({
disabled: true,
});
});

it('sets accessibilityState.disabled=false when disabled={false}', () => {
render(<FloatingLabel disabled={false}>Email</FloatingLabel>);
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({
disabled: false,
});
});

it('merges consumer accessibilityState with auto-set disabled', () => {
render(
<FloatingLabel disabled accessibilityState={{selected: true}}>
Email
</FloatingLabel>,
);
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({
disabled: true,
selected: true,
});
});

it('consumer accessibilityState.disabled overrides auto-set value', () => {
render(
<FloatingLabel disabled accessibilityState={{disabled: false}}>
Email
</FloatingLabel>,
);
expect(screen.UNSAFE_getByType(TextInput).props.accessibilityState).toMatchObject({
disabled: false,
});
});

it('does not set accessibilityState when disabled is not provided', () => {
render(<FloatingLabel>Email</FloatingLabel>);
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(<FloatingLabel secureTextEntry>Password</FloatingLabel>);
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(
<FloatingLabel secureTextEntry textContentType="newPassword">
Password
</FloatingLabel>,
);
expect(screen.UNSAFE_getByType(TextInput).props.textContentType).toBe('newPassword');
});

it('explicit autoComplete overrides the default', () => {
render(
<FloatingLabel secureTextEntry autoComplete="new-password">
Password
</FloatingLabel>,
);
expect(screen.UNSAFE_getByType(TextInput).props.autoComplete).toBe('new-password');
});

it('does not inject textContentType or autoComplete without secureTextEntry', () => {
render(<FloatingLabel>Email</FloatingLabel>);
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(<FloatingLabel errorMessage="Email is required">Email</FloatingLabel>);
expect(screen.getByText('Email is required')).toBeTruthy();
});

it('error text node has accessibilityLiveRegion="polite"', () => {
render(<FloatingLabel errorMessage="Email is required">Email</FloatingLabel>);
expect(screen.getByText('Email is required').props.accessibilityLiveRegion).toBe('polite');
});

it('renders helperText when provided', () => {
render(<FloatingLabel helperText="Enter your work email">Email</FloatingLabel>);
expect(screen.getByText('Enter your work email')).toBeTruthy();
});

it('helper text node has accessibilityLiveRegion="polite"', () => {
render(<FloatingLabel helperText="Enter your work email">Email</FloatingLabel>);
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(<FloatingLabel>Email</FloatingLabel>);
// Only the label text 'Email' should be present
expect(screen.getAllByText('Email')).toHaveLength(1);
});
});
Loading
Loading