Skip to content
Open
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
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 54 additions & 0 deletions e2e/components/TextInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,60 @@ test.describe('TextInput', () => {
}
})

test.describe('With Character Limit', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-textinput-features--with-character-limit',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`TextInput.With Character Limit.${theme}.png`)
})
})
}
})

test.describe('With Character Limit and Caption', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-textinput-features--with-character-limit-and-caption',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`TextInput.With Character Limit and Caption.${theme}.png`)
})
})
}
})

test.describe('With Character Limit Exceeded', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-textinput-features--with-character-limit-exceeded',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`TextInput.With Character Limit Exceeded.${theme}.png`)
})
})
}
})

test.describe('With Leading Visual', () => {
for (const theme of themes) {
test.describe(theme, () => {
Expand Down
12 changes: 12 additions & 0 deletions e2e/components/Textarea.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ const stories = [
id: 'components-textarea-features--custom-width',
title: 'Custom Width',
},
{
id: 'components-textarea-features--with-character-limit',
title: 'With Character Limit',
},
{
id: 'components-textarea-features--with-character-limit-and-caption',
title: 'With Character Limit and Caption',
},
{
id: 'components-textarea-features--with-character-limit-exceeded',
title: 'With Character Limit Exceeded',
},
{
id: 'components-textarea-dev--dev-default',
title: 'Dev Default',
Expand Down
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(--control-xsmall-gap);
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