diff --git a/components/input/src/input-field/input-field.js b/components/input/src/input-field/input-field.js index d9275dca17..6b6f2fad2d 100644 --- a/components/input/src/input-field/input-field.js +++ b/components/input/src/input-field/input-field.js @@ -41,6 +41,7 @@ class InputField extends React.Component { autoComplete, clearable, prefixIcon, + characterCountLimit, dataTest = 'dhis2-uiwidgets-inputfield', } = this.props @@ -84,6 +85,7 @@ class InputField extends React.Component { clearable={clearable} prefixIcon={prefixIcon} width={inputWidth} + characterCountLimit={characterCountLimit} /> @@ -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, diff --git a/components/input/src/input-field/input-field.prod.stories.js b/components/input/src/input-field/input-field.prod.stories.js index faacad2633..e77b1c635e 100644 --- a/components/input/src/input-field/input-field.prod.stories.js +++ b/components/input/src/input-field/input-field.prod.stories.js @@ -193,3 +193,102 @@ export const ClearableInput = (args) => { /> ) } + +export const WithcharacterCountLimit = (args) => { + const [value, setValue] = useState('') + return ( + setValue(e.value)} + characterCountLimit={50} + /> + ) +} +WithcharacterCountLimit.storyName = 'With characterCountLimit counter' + +export const WithcharacterCountLimitExceeded = () => ( + {}} + /> +) +WithcharacterCountLimitExceeded.storyName = 'With characterCountLimit exceeded' + +export const CharacterCountLimitDense = (args) => { + const [value, setValue] = useState('') + return ( + setValue(e.value)} + /> + ) +} +CharacterCountLimitDense.storyName = 'characterCountLimit + dense' + +export const CharacterCountLimitClearable = (args) => { + const [value, setValue] = useState('Some text') + return ( + setValue(e.value)} + /> + ) +} +CharacterCountLimitClearable.storyName = 'characterCountLimit + clearable' + +export const CharacterCountLimitWithValidation = (args) => { + const [value, setValue] = useState('Invalid value') + return ( + setValue(e.value)} + /> + ) +} +CharacterCountLimitWithValidation.storyName = + 'characterCountLimit + error + validationText' + +export const CharacterCountLimitClearableError = (args) => { + const [value, setValue] = useState('Text with all options') + return ( + setValue(e.value)} + /> + ) +} +CharacterCountLimitClearableError.storyName = + 'characterCountLimit + clearable + error' diff --git a/components/input/src/input/__tests__/input.test.js b/components/input/src/input/__tests__/input.test.js index a9b7468088..dba6947a83 100644 --- a/components/input/src/input/__tests__/input.test.js +++ b/components/input/src/input/__tests__/input.test.js @@ -26,4 +26,102 @@ describe('', () => { expect(onKeyDown).toHaveBeenCalledTimes(1) }) + + describe('character counter (characterCountLimit)', () => { + it('renders a counter when characterCountLimit is provided', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('dhis2-uicore-input-counter')).toBeTruthy() + }) + + it('shows the correct current/max text', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('dhis2-uicore-input-counter').textContent).toBe( + '5/10' + ) + }) + + it('shows 0/max when value is empty', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('dhis2-uicore-input-counter').textContent).toBe( + '0/50' + ) + }) + + it('applies exceeds-maximum class when value exceeds characterCountLimit', () => { + const { getByTestId } = render( + + ) + 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( + + ) + 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( + + ) + expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull() + }) + + it('does not render the counter when readOnly', () => { + const { queryByTestId } = render( + + ) + expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull() + }) + + it('does not set the native maxLength attribute (soft limit)', () => { + render() + const input = screen.getByRole('textbox') + expect(input).not.toHaveAttribute('maxlength') + }) + + it('does not render a counter when characterCountLimit is not provided', () => { + const { queryByTestId } = render() + expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull() + }) + + it('does not render a counter when characterCountLimit is 0', () => { + const { queryByTestId } = render( + + ) + expect(queryByTestId('dhis2-uicore-input-counter')).toBeNull() + }) + + it('sets aria-describedby on the input when name is provided', () => { + render( + + ) + 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( + + ) + 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( + + ) + expect(getByTestId('dhis2-uicore-input-counter')).toBeTruthy() + }) + }) }) diff --git a/components/input/src/input/input.js b/components/input/src/input/input.js index fae04cbc25..8c91d1836f 100644 --- a/components/input/src/input/input.js +++ b/components/input/src/input/input.js @@ -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 { @@ -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 (
@@ -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} @@ -219,7 +270,29 @@ export class Input extends Component { 'read-only': readOnly, })} /> - {clearable && value?.length ? ( + {showCounter && ( + + {showClearButton && ( + + )} + + {valueLength}/{characterCountLimit} + + + )} + {!showCounter && showClearButton && ( - ) : null} + )}
) @@ -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, diff --git a/components/input/src/input/input.prod.stories.js b/components/input/src/input/input.prod.stories.js index 51e39fc357..6685173b22 100644 --- a/components/input/src/input/input.prod.stories.js +++ b/components/input/src/input/input.prod.stories.js @@ -123,6 +123,71 @@ ValueTextOverflow.args = { warning: true, } +export const WithMaxLength = (args) => { + const [value, setValue] = React.useState('') + return setValue(e.value)} /> +} +WithMaxLength.args = { + name: 'characterCountLimit', + characterCountLimit: 50, + placeholder: 'Max 50 characters', +} +WithMaxLength.storyName = 'With characterCountLimit counter' + +export const WithMaxLengthExceeded = Template.bind({}) +WithMaxLengthExceeded.args = { + characterCountLimit: 20, + value: 'This text exceeds the character limit', +} +WithMaxLengthExceeded.storyName = 'With characterCountLimit exceeded' + +export const MaxLengthDense = (args) => { + const [value, setValue] = React.useState('') + return setValue(e.value)} /> +} +MaxLengthDense.args = { + name: 'characterCountLimit-dense', + characterCountLimit: 30, + dense: true, + placeholder: 'Dense with counter', +} +MaxLengthDense.storyName = 'characterCountLimit + dense' + +export const MaxLengthClearable = (args) => { + const [value, setValue] = React.useState('Some text') + return setValue(e.value)} /> +} +MaxLengthClearable.args = { + name: 'characterCountLimit-clearable', + characterCountLimit: 40, + clearable: true, +} +MaxLengthClearable.storyName = 'characterCountLimit + clearable' + +export const MaxLengthWithError = (args) => { + const [value, setValue] = React.useState('Invalid value') + return setValue(e.value)} /> +} +MaxLengthWithError.args = { + name: 'characterCountLimit-error', + characterCountLimit: 50, + error: true, +} +MaxLengthWithError.storyName = 'characterCountLimit + error status' + +export const MaxLengthClearableError = (args) => { + const [value, setValue] = React.useState('Text with all options') + return setValue(e.value)} /> +} +MaxLengthClearableError.args = { + name: 'characterCountLimit-clearable-error', + characterCountLimit: 30, + clearable: true, + error: true, +} +MaxLengthClearableError.storyName = + 'characterCountLimit + clearable + error status' + export const RTLErrorPlaceholder = (args) => (
diff --git a/components/input/types/index.d.ts b/components/input/types/index.d.ts index ac24893603..106bf91ad6 100644 --- a/components/input/types/index.d.ts +++ b/components/input/types/index.d.ts @@ -40,6 +40,10 @@ export interface InputProps { * The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete) */ autoComplete?: string + /** + * Soft maximum character length. Displays a counter showing current/max characters. Counter turns red when exceeded but typing is not prevented + */ + characterCountLimit?: number className?: string /** * Makes the input clearable @@ -188,6 +192,10 @@ export interface InputFieldProps { * The [native `max` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-max), for use when `type` is `'number'` */ max?: string + /** + * Soft maximum character length. Displays a counter showing current/max characters. Counter turns red when exceeded but typing is not prevented + */ + characterCountLimit?: number /** * The [native `min` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-min), for use when `type` is `'number'` */