Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
5 changes: 5 additions & 0 deletions packages/react/src/TextInput/TextInput.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@
"defaultValue": "false",
"description": "Creates a full-width input element"
},
{
"name": "characterLimit",
"type": "number",
"description": "The maximum number of characters allowed in the input"
},
{
"name": "contrast",
"type": "boolean",
Expand Down
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>
)
}
10 changes: 10 additions & 0 deletions packages/react/src/TextInput/TextInput.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.CharacterCounter {
display: flex;
align-items: center;
gap: var(--base-size-4);
Comment thread
lindseywild marked this conversation as resolved.
Outdated
color: var(--fgColor-muted);
}

.CharacterCounter--error {
color: var(--fgColor-danger);
}
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