Skip to content

Commit 680edb2

Browse files
committed
feat(input): character count indicator
1 parent 0c9564c commit 680edb2

6 files changed

Lines changed: 356 additions & 3 deletions

File tree

components/input/src/input-field/input-field.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class InputField extends React.Component {
4141
autoComplete,
4242
clearable,
4343
prefixIcon,
44+
characterCountLimit,
4445
dataTest = 'dhis2-uiwidgets-inputfield',
4546
} = this.props
4647

@@ -84,6 +85,7 @@ class InputField extends React.Component {
8485
clearable={clearable}
8586
prefixIcon={prefixIcon}
8687
width={inputWidth}
88+
characterCountLimit={characterCountLimit}
8789
/>
8890
</Box>
8991
</Field>
@@ -94,6 +96,8 @@ class InputField extends React.Component {
9496
const InputFieldProps = {
9597
/** The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete) */
9698
autoComplete: PropTypes.string,
99+
/** Soft maximum character length. Displays a counter showing current/max characters. Counter turns red when exceeded but typing is not prevented */
100+
characterCountLimit: PropTypes.number,
97101
className: PropTypes.string,
98102
/** Makes the input field clearable */
99103
clearable: PropTypes.bool,

components/input/src/input-field/input-field.prod.stories.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,102 @@ export const ClearableInput = (args) => {
193193
/>
194194
)
195195
}
196+
197+
export const WithcharacterCountLimit = (args) => {
198+
const [value, setValue] = useState('')
199+
return (
200+
<InputField
201+
{...args}
202+
name="characterCountLimit-input"
203+
label="Description"
204+
helpText="Maximum of 50 characters"
205+
placeholder="Enter a description"
206+
value={value}
207+
onChange={(e) => setValue(e.value)}
208+
characterCountLimit={50}
209+
/>
210+
)
211+
}
212+
WithcharacterCountLimit.storyName = 'With characterCountLimit counter'
213+
214+
export const WithcharacterCountLimitExceeded = () => (
215+
<InputField
216+
name="characterCountLimit-exceeded"
217+
label="Short title"
218+
value="This text exceeds the maximum character limit set on this field"
219+
characterCountLimit={20}
220+
onChange={() => {}}
221+
/>
222+
)
223+
WithcharacterCountLimitExceeded.storyName = 'With characterCountLimit exceeded'
224+
225+
export const CharacterCountLimitDense = (args) => {
226+
const [value, setValue] = useState('')
227+
return (
228+
<InputField
229+
{...args}
230+
name="characterCountLimit-dense"
231+
label="Dense with counter"
232+
dense
233+
characterCountLimit={30}
234+
placeholder="Dense input"
235+
value={value}
236+
onChange={(e) => setValue(e.value)}
237+
/>
238+
)
239+
}
240+
CharacterCountLimitDense.storyName = 'characterCountLimit + dense'
241+
242+
export const CharacterCountLimitClearable = (args) => {
243+
const [value, setValue] = useState('Some text')
244+
return (
245+
<InputField
246+
{...args}
247+
name="characterCountLimit-clearable"
248+
label="Clearable with counter"
249+
characterCountLimit={40}
250+
clearable
251+
value={value}
252+
onChange={(e) => setValue(e.value)}
253+
/>
254+
)
255+
}
256+
CharacterCountLimitClearable.storyName = 'characterCountLimit + clearable'
257+
258+
export const CharacterCountLimitWithValidation = (args) => {
259+
const [value, setValue] = useState('Invalid value')
260+
return (
261+
<InputField
262+
{...args}
263+
name="characterCountLimit-validation"
264+
label="With error status"
265+
characterCountLimit={50}
266+
error
267+
validationText="This field has a validation error"
268+
value={value}
269+
onChange={(e) => setValue(e.value)}
270+
/>
271+
)
272+
}
273+
CharacterCountLimitWithValidation.storyName =
274+
'characterCountLimit + error + validationText'
275+
276+
export const CharacterCountLimitClearableError = (args) => {
277+
const [value, setValue] = useState('Text with all options')
278+
return (
279+
<InputField
280+
{...args}
281+
name="characterCountLimit-all"
282+
label="All options combined"
283+
characterCountLimit={30}
284+
clearable
285+
error
286+
validationText="Too many characters"
287+
helpText="Keep it short"
288+
value={value}
289+
onChange={(e) => setValue(e.value)}
290+
/>
291+
)
292+
}
293+
CharacterCountLimitClearableError.storyName =
294+
'characterCountLimit + clearable + error'

components/input/src/input/__tests__/input.test.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,102 @@ describe('<Input>', () => {
2626

2727
expect(onKeyDown).toHaveBeenCalledTimes(1)
2828
})
29+
30+
describe('character counter (characterCountLimit)', () => {
31+
it('renders a counter when characterCountLimit is provided', () => {
32+
const { getByTestId } = render(
33+
<Input value="hello" characterCountLimit={10} />
34+
)
35+
expect(getByTestId('dhis2-uicore-input-counter')).toBeTruthy()
36+
})
37+
38+
it('shows the correct current/max text', () => {
39+
const { getByTestId } = render(
40+
<Input value="hello" characterCountLimit={10} />
41+
)
42+
expect(getByTestId('dhis2-uicore-input-counter').textContent).toBe(
43+
'5/10'
44+
)
45+
})
46+
47+
it('shows 0/max when value is empty', () => {
48+
const { getByTestId } = render(
49+
<Input value="" characterCountLimit={50} />
50+
)
51+
expect(getByTestId('dhis2-uicore-input-counter').textContent).toBe(
52+
'0/50'
53+
)
54+
})
55+
56+
it('applies exceeds-maximum class when value exceeds characterCountLimit', () => {
57+
const { getByTestId } = render(
58+
<Input value="this exceeds" characterCountLimit={5} />
59+
)
60+
const counter = getByTestId('dhis2-uicore-input-counter')
61+
expect(counter.className).toMatch(/exceeds-maximum/)
62+
})
63+
64+
it('does not apply exceeds-maximum class when within limit', () => {
65+
const { getByTestId } = render(
66+
<Input value="hi" characterCountLimit={10} />
67+
)
68+
const counter = getByTestId('dhis2-uicore-input-counter')
69+
expect(counter.className).not.toMatch(/exceeds-maximum/)
70+
})
71+
72+
it('does not render the counter when disabled', () => {
73+
const { queryByTestId } = render(
74+
<Input value="hello" characterCountLimit={10} disabled />
75+
)
76+
expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull()
77+
})
78+
79+
it('does not render the counter when readOnly', () => {
80+
const { queryByTestId } = render(
81+
<Input value="hello" characterCountLimit={10} readOnly />
82+
)
83+
expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull()
84+
})
85+
86+
it('does not set the native maxLength attribute (soft limit)', () => {
87+
render(<Input name="test" value="hello" characterCountLimit={10} />)
88+
const input = screen.getByRole('textbox')
89+
expect(input).not.toHaveAttribute('maxlength')
90+
})
91+
92+
it('does not render a counter when characterCountLimit is not provided', () => {
93+
const { queryByTestId } = render(<Input value="hello" />)
94+
expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull()
95+
})
96+
97+
it('does not render a counter when characterCountLimit is 0', () => {
98+
const { queryByTestId } = render(
99+
<Input value="hello" characterCountLimit={0} />
100+
)
101+
expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull()
102+
})
103+
104+
it('sets aria-describedby on the input when name is provided', () => {
105+
render(
106+
<Input name="myfield" value="hello" characterCountLimit={10} />
107+
)
108+
const input = screen.getByRole('textbox')
109+
expect(input).toHaveAttribute('aria-describedby', 'myfield-counter')
110+
})
111+
112+
it('gives the counter span a matching id for aria-describedby', () => {
113+
const { getByTestId } = render(
114+
<Input name="myfield" value="hello" characterCountLimit={10} />
115+
)
116+
const counter = getByTestId('dhis2-uicore-input-counter')
117+
expect(counter.id).toBe('myfield-counter')
118+
})
119+
120+
it('renders counter alongside the clear button when clearable', () => {
121+
const { getByTestId } = render(
122+
<Input value="hello" characterCountLimit={10} clearable />
123+
)
124+
expect(getByTestId('dhis2-uicore-input-counter')).toBeTruthy()
125+
})
126+
})
29127
})

components/input/src/input/input.js

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,41 @@ const styles = css`
8989
color: ${theme.disabled};
9090
cursor: not-allowed;
9191
}
92+
93+
.input-end-items {
94+
position: absolute;
95+
display: flex;
96+
align-items: center;
97+
gap: 6px;
98+
pointer-events: none;
99+
}
100+
101+
.input-end-items .clear-button {
102+
position: static;
103+
inset-inline-end: unset;
104+
pointer-events: auto;
105+
}
106+
107+
.character-counter {
108+
font-size: 12px;
109+
line-height: 14px;
110+
font-variant-numeric: tabular-nums;
111+
color: ${colors.grey700};
112+
white-space: nowrap;
113+
}
114+
115+
.character-counter.exceeds-maximum {
116+
color: ${theme.error};
117+
font-weight: 500;
118+
}
119+
120+
.input-has-counter input {
121+
padding-inline-end: 60px;
122+
}
123+
124+
.input-has-counter.input-clearable input {
125+
padding-inline-end: 82px;
126+
}
92127
`
93128

94129
export class Input extends Component {
@@ -172,18 +207,33 @@ export class Input extends Component {
172207
clearable,
173208
prefixIcon,
174209
width,
210+
characterCountLimit,
175211
} = this.props
176212

177213
const statusIcon = error || loading || valid || warning
178214
const clearButtonPadding = statusIcon ? '40px' : '10px'
179215

216+
const showCounter =
217+
characterCountLimit != null &&
218+
characterCountLimit > 0 &&
219+
!disabled &&
220+
!readOnly
221+
const valueLength = value?.length || 0
222+
const exceedsMaximum = showCounter && valueLength > characterCountLimit
223+
const counterId = showCounter && name ? `${name}-counter` : undefined
224+
225+
const showClearButton = clearable && value?.length > 0
226+
227+
const endItemsEnd = statusIcon ? '38px' : '8px'
228+
180229
return (
181230
<div
182231
className={cx(
183232
'input',
184233
className,
185234
{ 'input-prefix-icon': prefixIcon },
186-
{ 'input-clearable': clearable }
235+
{ 'input-clearable': clearable },
236+
{ 'input-has-counter': showCounter }
187237
)}
188238
data-test={dataTest}
189239
>
@@ -192,6 +242,7 @@ export class Input extends Component {
192242
aria-label={ariaLabel}
193243
aria-controls={ariaControls}
194244
aria-haspopup={ariaHaspopup}
245+
aria-describedby={counterId}
195246
role={role}
196247
id={name}
197248
name={name}
@@ -219,15 +270,37 @@ export class Input extends Component {
219270
'read-only': readOnly,
220271
})}
221272
/>
222-
{clearable && value?.length ? (
273+
{showCounter && (
274+
<span className="input-end-items">
275+
{showClearButton && (
276+
<button
277+
type="button"
278+
onClick={this.handleClear}
279+
className="clear-button"
280+
>
281+
<IconCross16 color={colors.white} />
282+
</button>
283+
)}
284+
<span
285+
id={counterId}
286+
className={cx('character-counter', {
287+
'exceeds-maximum': exceedsMaximum,
288+
})}
289+
data-test={`${dataTest}-counter`}
290+
>
291+
{valueLength}/{characterCountLimit}
292+
</span>
293+
</span>
294+
)}
295+
{!showCounter && showClearButton && (
223296
<button
224297
type="button"
225298
onClick={this.handleClear}
226299
className="clear-button"
227300
>
228301
<IconCross16 color={colors.white} />
229302
</button>
230-
) : null}
303+
)}
231304
<StatusIcon
232305
error={error}
233306
valid={valid}
@@ -277,6 +350,10 @@ export class Input extends Component {
277350
background: ${colors.grey500};
278351
padding: 1px;
279352
}
353+
354+
.input-end-items {
355+
inset-inline-end: ${endItemsEnd};
356+
}
280357
`}</style>
281358
</div>
282359
)
@@ -292,6 +369,8 @@ Input.propTypes = {
292369
ariaLabel: PropTypes.string,
293370
/** The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete) */
294371
autoComplete: PropTypes.string,
372+
/** Soft maximum character length. Displays a counter showing current/max characters. Counter turns red when exceeded but typing is not prevented */
373+
characterCountLimit: PropTypes.number,
295374
className: PropTypes.string,
296375
/** Makes the input field clearable */
297376
clearable: PropTypes.bool,

0 commit comments

Comments
 (0)