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
4 changes: 4 additions & 0 deletions components/input/src/input-field/input-field.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class InputField extends React.Component {
autoComplete,
clearable,
prefixIcon,
characterCountLimit,
dataTest = 'dhis2-uiwidgets-inputfield',
} = this.props

Expand Down Expand Up @@ -84,6 +85,7 @@ class InputField extends React.Component {
clearable={clearable}
prefixIcon={prefixIcon}
width={inputWidth}
characterCountLimit={characterCountLimit}
/>
</Box>
</Field>
Expand All @@ -94,6 +96,8 @@ class InputField extends React.Component {
const InputFieldProps = {
/** The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete) */
autoComplete: PropTypes.string,
/** Soft maximum character length. Displays a counter showing current/max characters. Counter turns red when exceeded but typing is not prevented */
characterCountLimit: PropTypes.number,
className: PropTypes.string,
/** Makes the input field clearable */
clearable: PropTypes.bool,
Expand Down
99 changes: 99 additions & 0 deletions components/input/src/input-field/input-field.prod.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,102 @@ export const ClearableInput = (args) => {
/>
)
}

export const WithcharacterCountLimit = (args) => {
const [value, setValue] = useState('')
return (
<InputField
{...args}
name="characterCountLimit-input"
label="Description"
helpText="Maximum of 50 characters"
placeholder="Enter a description"
value={value}
onChange={(e) => setValue(e.value)}
characterCountLimit={50}
/>
)
}
WithcharacterCountLimit.storyName = 'With characterCountLimit counter'

export const WithcharacterCountLimitExceeded = () => (
<InputField
name="characterCountLimit-exceeded"
label="Short title"
value="This text exceeds the maximum character limit set on this field"
characterCountLimit={20}
onChange={() => {}}
/>
)
WithcharacterCountLimitExceeded.storyName = 'With characterCountLimit exceeded'

export const CharacterCountLimitDense = (args) => {
const [value, setValue] = useState('')
return (
<InputField
{...args}
name="characterCountLimit-dense"
label="Dense with counter"
dense
characterCountLimit={30}
placeholder="Dense input"
value={value}
onChange={(e) => setValue(e.value)}
/>
)
}
CharacterCountLimitDense.storyName = 'characterCountLimit + dense'

export const CharacterCountLimitClearable = (args) => {
const [value, setValue] = useState('Some text')
return (
<InputField
{...args}
name="characterCountLimit-clearable"
label="Clearable with counter"
characterCountLimit={40}
clearable
value={value}
onChange={(e) => setValue(e.value)}
/>
)
}
CharacterCountLimitClearable.storyName = 'characterCountLimit + clearable'

export const CharacterCountLimitWithValidation = (args) => {
const [value, setValue] = useState('Invalid value')
return (
<InputField
{...args}
name="characterCountLimit-validation"
label="With error status"
characterCountLimit={50}
error
validationText="This field has a validation error"
value={value}
onChange={(e) => setValue(e.value)}
/>
)
}
CharacterCountLimitWithValidation.storyName =
'characterCountLimit + error + validationText'

export const CharacterCountLimitClearableError = (args) => {
const [value, setValue] = useState('Text with all options')
return (
<InputField
{...args}
name="characterCountLimit-all"
label="All options combined"
characterCountLimit={30}
clearable
error
validationText="Too many characters"
helpText="Keep it short"
value={value}
onChange={(e) => setValue(e.value)}
/>
)
}
CharacterCountLimitClearableError.storyName =
'characterCountLimit + clearable + error'
98 changes: 98 additions & 0 deletions components/input/src/input/__tests__/input.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,102 @@ describe('<Input>', () => {

expect(onKeyDown).toHaveBeenCalledTimes(1)
})

describe('character counter (characterCountLimit)', () => {
it('renders a counter when characterCountLimit is provided', () => {
const { getByTestId } = render(
<Input value="hello" characterCountLimit={10} />
)
expect(getByTestId('dhis2-uicore-input-counter')).toBeTruthy()
})

it('shows the correct current/max text', () => {
const { getByTestId } = render(
<Input value="hello" characterCountLimit={10} />
)
expect(getByTestId('dhis2-uicore-input-counter').textContent).toBe(
'5/10'
)
})

it('shows 0/max when value is empty', () => {
const { getByTestId } = render(
<Input value="" characterCountLimit={50} />
)
expect(getByTestId('dhis2-uicore-input-counter').textContent).toBe(
'0/50'
)
})

it('applies exceeds-maximum class when value exceeds characterCountLimit', () => {
const { getByTestId } = render(
<Input value="this exceeds" characterCountLimit={5} />
)
const counter = getByTestId('dhis2-uicore-input-counter')
expect(counter.className).toMatch(/exceeds-maximum/)
})

it('does not apply exceeds-maximum class when within limit', () => {
const { getByTestId } = render(
<Input value="hi" characterCountLimit={10} />
)
const counter = getByTestId('dhis2-uicore-input-counter')
expect(counter.className).not.toMatch(/exceeds-maximum/)
})

it('does not render the counter when disabled', () => {
const { queryByTestId } = render(
<Input value="hello" characterCountLimit={10} disabled />
)
expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull()
})

it('does not render the counter when readOnly', () => {
const { queryByTestId } = render(
<Input value="hello" characterCountLimit={10} readOnly />
)
expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull()
})

it('does not set the native maxLength attribute (soft limit)', () => {
render(<Input name="test" value="hello" characterCountLimit={10} />)
const input = screen.getByRole('textbox')
expect(input).not.toHaveAttribute('maxlength')
})

it('does not render a counter when characterCountLimit is not provided', () => {
const { queryByTestId } = render(<Input value="hello" />)
expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull()
})

it('does not render a counter when characterCountLimit is 0', () => {
const { queryByTestId } = render(
<Input value="hello" characterCountLimit={0} />
)
expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull()
})

it('sets aria-describedby on the input when name is provided', () => {
render(
<Input name="myfield" value="hello" characterCountLimit={10} />
)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('aria-describedby', 'myfield-counter')
})

it('gives the counter span a matching id for aria-describedby', () => {
const { getByTestId } = render(
<Input name="myfield" value="hello" characterCountLimit={10} />
)
const counter = getByTestId('dhis2-uicore-input-counter')
expect(counter.id).toBe('myfield-counter')
})

it('renders counter alongside the clear button when clearable', () => {
const { getByTestId } = render(
<Input value="hello" characterCountLimit={10} clearable />
)
expect(getByTestId('dhis2-uicore-input-counter')).toBeTruthy()
})
})
})
85 changes: 82 additions & 3 deletions components/input/src/input/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,41 @@ const styles = css`
color: ${theme.disabled};
cursor: not-allowed;
}

.input-end-items {
position: absolute;
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
}

.input-end-items .clear-button {
position: static;
inset-inline-end: unset;
pointer-events: auto;
}

.character-counter {
font-size: 12px;
line-height: 14px;
font-variant-numeric: tabular-nums;
color: ${colors.grey700};
white-space: nowrap;
}

.character-counter.exceeds-maximum {
color: ${theme.error};
font-weight: 500;
}

.input-has-counter input {
padding-inline-end: 60px;
}

.input-has-counter.input-clearable input {
padding-inline-end: 82px;
}
`

export class Input extends Component {
Expand Down Expand Up @@ -172,18 +207,33 @@ export class Input extends Component {
clearable,
prefixIcon,
width,
characterCountLimit,
} = this.props

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

const showCounter =
characterCountLimit != null &&
characterCountLimit > 0 &&
!disabled &&
!readOnly
const valueLength = value?.length || 0
const exceedsMaximum = showCounter && valueLength > characterCountLimit
const counterId = showCounter && name ? `${name}-counter` : undefined

const showClearButton = clearable && value?.length > 0

const endItemsEnd = statusIcon ? '38px' : '8px'

return (
<div
className={cx(
'input',
className,
{ 'input-prefix-icon': prefixIcon },
{ 'input-clearable': clearable }
{ 'input-clearable': clearable },
{ 'input-has-counter': showCounter }
)}
data-test={dataTest}
>
Expand All @@ -192,6 +242,7 @@ export class Input extends Component {
aria-label={ariaLabel}
aria-controls={ariaControls}
aria-haspopup={ariaHaspopup}
aria-describedby={counterId}
role={role}
id={name}
name={name}
Expand Down Expand Up @@ -219,15 +270,37 @@ export class Input extends Component {
'read-only': readOnly,
})}
/>
{clearable && value?.length ? (
{showCounter && (
<span className="input-end-items">
{showClearButton && (
<button
type="button"
onClick={this.handleClear}
className="clear-button"
>
<IconCross16 color={colors.white} />
</button>
)}
<span
id={counterId}
className={cx('character-counter', {
'exceeds-maximum': exceedsMaximum,
})}
data-test={`${dataTest}-counter`}
>
{valueLength}/{characterCountLimit}
</span>
</span>
)}
{!showCounter && showClearButton && (
<button
type="button"
onClick={this.handleClear}
className="clear-button"
>
<IconCross16 color={colors.white} />
</button>
) : null}
)}
<StatusIcon
error={error}
valid={valid}
Expand Down Expand Up @@ -277,6 +350,10 @@ export class Input extends Component {
background: ${colors.grey500};
padding: 1px;
}

.input-end-items {
inset-inline-end: ${endItemsEnd};
}
`}</style>
</div>
)
Expand All @@ -292,6 +369,8 @@ Input.propTypes = {
ariaLabel: PropTypes.string,
/** The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete) */
autoComplete: PropTypes.string,
/** Soft maximum character length. Displays a counter showing current/max characters. Counter turns red when exceeded but typing is not prevented */
characterCountLimit: PropTypes.number,
className: PropTypes.string,
/** Makes the input field clearable */
clearable: PropTypes.bool,
Expand Down
Loading
Loading