Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
5 changes: 5 additions & 0 deletions .changeset/four-fans-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Adds character counts to TextInput and TextArea components
41 changes: 41 additions & 0 deletions packages/react/src/TextInput/TextInput.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,44 @@ export const WithAutocompleteAttribute = () => (
</FormControl>
</form>
)

export const WithCharacterLimit = () => {
const [value, setValue] = useState('')

return (
<form>
<FormControl>
<FormControl.Label>Username</FormControl.Label>
<TextInput value={value} onChange={e => setValue(e.target.value)} characterLimit={20} />
</FormControl>
</form>
)
}

export const WithCharacterLimitAndCaption = () => {
const [value, setValue] = useState('')

return (
<form>
<FormControl>
<FormControl.Label>Username</FormControl.Label>
<TextInput value={value} onChange={e => setValue(e.target.value)} characterLimit={20} />
<FormControl.Caption>Choose a unique username</FormControl.Caption>
</FormControl>
</form>
)
}

export const WithCharacterLimitExceeded = () => {
const [value, setValue] = useState('This is a very long text that exceeds the limit')

return (
<form>
<FormControl>
<FormControl.Label>Bio</FormControl.Label>
<TextInput value={value} onChange={e => setValue(e.target.value)} characterLimit={20} />
<FormControl.Caption>Keep it short</FormControl.Caption>
</FormControl>
</form>
)
}
105 changes: 100 additions & 5 deletions packages/react/src/TextInput/TextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('TextInput', () => {
})

it('renders error', () => {
expect(render(<TextInput name="zipcode" validationStatus="error" />).container).toMatchSnapshot()
expect(render(<TextInput name="zipcode" validationStatus="error" value="" />).container).toMatchSnapshot()
})

it('renders sets aria-invalid="true" on error', () => {
Expand All @@ -40,15 +40,15 @@ describe('TextInput', () => {
})

it('renders contrast', () => {
expect(render(<TextInput name="zipcode" contrast />).container).toMatchSnapshot()
expect(render(<TextInput name="zipcode" contrast value="" />).container).toMatchSnapshot()
})

it('renders monospace', () => {
expect(render(<TextInput name="zipcode" monospace />).container).toMatchSnapshot()
expect(render(<TextInput name="zipcode" monospace value="" />).container).toMatchSnapshot()
})

it('renders placeholder', () => {
expect(render(<TextInput name="zipcode" placeholder={'560076'} />).container).toMatchSnapshot()
expect(render(<TextInput name="zipcode" placeholder={'560076'} value="" />).container).toMatchSnapshot()
})

it('renders leadingVisual', () => {
Expand Down Expand Up @@ -194,7 +194,7 @@ describe('TextInput', () => {
})

it('should render a password input', () => {
expect(render(<TextInput name="password" type="password" />).container).toMatchSnapshot()
expect(render(<TextInput name="password" type="password" value="" />).container).toMatchSnapshot()
})

it('should not override prop aria-invalid', () => {
Expand Down Expand Up @@ -270,4 +270,99 @@ describe('TextInput', () => {
const {getByRole} = render(<TextInput />)
expect(getByRole('textbox')).not.toHaveAttribute('aria-describedby')
})

describe('character counter', () => {
it('should render character counter when characterLimit is provided', () => {
const {container} = render(<TextInput characterLimit={20} />)
expect(container.textContent).toContain('20 characters remaining')
})

it('should update character count on input', async () => {
const user = userEvent.setup()
const {getByRole, container} = render(<TextInput characterLimit={20} />)
const input = getByRole('textbox')

await user.type(input, 'Hello')
expect(container.textContent).toContain('15 characters remaining')
})

it('should show singular "character" when one character remains', async () => {
const user = userEvent.setup()
const {getByRole, container} = render(<TextInput characterLimit={5} />)
const input = getByRole('textbox')

await user.type(input, 'Test')
expect(container.textContent).toContain('1 character remaining')
})

it('should show error state when character limit is exceeded', async () => {
const user = userEvent.setup()
const {getByRole, container} = render(<TextInput characterLimit={5} />)
const input = getByRole('textbox')

await user.type(input, 'Hello World')
expect(container.textContent).toContain('6 characters over')
expect(input).toHaveAttribute('aria-invalid', 'true')
})

it('should show alert icon when character limit is exceeded', async () => {
const user = userEvent.setup()
const {getByRole, container} = render(<TextInput characterLimit={5} />)
const input = getByRole('textbox')

await user.type(input, 'Hello World')
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})

it('should clear error state when back under limit', async () => {
const user = userEvent.setup()
const {getByRole, container} = render(<TextInput characterLimit={10} defaultValue="Hello World!" />)
const input = getByRole('textbox')

expect(container.textContent).toContain('2 characters over')

await user.clear(input)
await user.type(input, 'Hello')

expect(container.textContent).toContain('5 characters remaining')
expect(input).not.toHaveAttribute('aria-invalid', 'true')
})

it('should have aria-describedby pointing to static message', () => {
const {getByRole, container} = render(<TextInput characterLimit={20} />)
const input = getByRole('textbox')
const describedBy = input.getAttribute('aria-describedby')
expect(describedBy).toBeTruthy()

const staticMessage = Array.from(container.querySelectorAll('[id]')).find(el =>
el.textContent.includes('You can enter up to'),
)
expect(staticMessage).toBeTruthy()
expect(describedBy).toContain(staticMessage?.id)
})

it('should have screen reader announcement element', () => {
const {container} = render(<TextInput characterLimit={20} />)
const srElement = container.querySelector('[aria-live="polite"]')
expect(srElement).toBeInTheDocument()
expect(srElement).toHaveAttribute('role', 'status')
})

it('should have static screen reader message', () => {
const {container} = render(<TextInput characterLimit={20} />)
expect(container.textContent).toContain('You can enter up to 20 characters')
})

it('should show singular character in static message when limit is 1', () => {
const {container} = render(<TextInput characterLimit={1} />)
expect(container.textContent).toContain('You can enter up to 1 character')
})

it('should not announce on initial load', () => {
const {container} = render(<TextInput characterLimit={20} defaultValue="Hello" />)
const srElement = container.querySelector('[aria-live="polite"]')
expect(srElement?.textContent).toBe('')
})
})
})
Loading
Loading