props.onChange({ target: { value } })} />,
textarea: props => ,
+ textAreaWithWordCount: props => ,
radioGroup: props => {
if (!props.options) {
throw new Error(`radioGroup '${props.name}' has undefined options`);
diff --git a/src/index.jsx b/src/index.jsx
index b58d8d48..d16b3548 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -69,5 +69,6 @@ export { default as Tasklist } from './tasklist';
export { default as TrainingSummary } from './training-summary';
export { default as WidthContainer } from './width-container';
export { default as Wrapper } from './wrapper';
+export { default as TextAreaWithWordCount } from './text-area-word-count';
export * from './layouts';
diff --git a/src/text-area-word-count/index.jsx b/src/text-area-word-count/index.jsx
new file mode 100644
index 00000000..4b2b2902
--- /dev/null
+++ b/src/text-area-word-count/index.jsx
@@ -0,0 +1,29 @@
+import React, { useState } from 'react';
+import { TextArea } from '@ukhomeoffice/react-components';
+import classNames from 'classnames';
+import WordCountHintMessage from './wordcount-hint-message';
+import omit from 'lodash/omit';
+
+export default function TextAreaWithWordCount(props) {
+
+ const { value, maxWordCount, error, values, name } = props;
+
+ const [content, setContent] = useState(value || '');
+
+ const formErrorClass = classNames({
+ 'govuk-form-group': true,
+ 'govuk-character-count': true,
+ 'govuk-form-group--error': error
+ });
+
+ return (
+
+
+ );
+}
diff --git a/src/text-area-word-count/index.scss b/src/text-area-word-count/index.scss
new file mode 100644
index 00000000..d6a93bd9
--- /dev/null
+++ b/src/text-area-word-count/index.scss
@@ -0,0 +1,28 @@
+// Added from govuk-frotnend
+
+.govuk-character-count {
+ @include govuk-responsive-margin(6, "bottom");
+
+ .govuk-form-group,
+ .govuk-textarea {
+ margin-bottom: govuk-spacing(1);
+ }
+}
+
+.govuk-character-count__message {
+ margin-top: 0;
+ margin-bottom: 0;
+
+ &::after {
+ // Zero-width space that will reserve vertical space when no hint is
+ // provided as:
+ // - setting a min-height is not possible without a magic number because
+ // the line-height is set by the `govuk-font` call above
+ // - using `:empty` is not possible as the hint macro outputs line breaks
+ content: "\200B";
+ }
+}
+
+.govuk-character-count__message--disabled {
+ visibility: hidden;
+}
diff --git a/src/text-area-word-count/index.spec.jsx b/src/text-area-word-count/index.spec.jsx
new file mode 100644
index 00000000..0172512f
--- /dev/null
+++ b/src/text-area-word-count/index.spec.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import WordCountHintMessage from './wordcount-hint-message';
+import { describe, test, expect } from '@jest/globals';
+
+describe('', () => {
+ const id = 'applicantTrainingUseAtWork';
+ const wordCountHintId = '#applicantTrainingUseAtWork-wordcount-hint';
+
+ test('displays max words remaining when wordCount is not provided', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(wordCountHintId).text()).toContain('You have 10 words remaining');
+ });
+
+ test('displays remaining words when wordCount is less than maxWordCount', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(wordCountHintId).text()).toContain('You have 3 words remaining');
+ });
+
+ test('displays no remaining words when wordCount is equal to maxWordCount', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(wordCountHintId).text()).toContain('You have 0 words remaining');
+ });
+
+ test('displays too many words when wordCount is greater than maxWordCount', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(wordCountHintId).text()).toContain('You have 2 words too many');
+ });
+
+ test('displays singular word when there is only one word remaining', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(wordCountHintId).text()).toContain('You have 1 word remaining');
+ });
+
+ test('displays singular word when there is only one word too many', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(wordCountHintId).text()).toContain('You have 1 word too many');
+ });
+});
diff --git a/src/text-area-word-count/wordcount-hint-message.jsx b/src/text-area-word-count/wordcount-hint-message.jsx
new file mode 100644
index 00000000..7b962669
--- /dev/null
+++ b/src/text-area-word-count/wordcount-hint-message.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+const WordCountHintMessage = ({ content, id, maxWordCount = 0 }) => {
+
+ const wordCount = content?.split(/\s+/).filter(Boolean).length ?? 0;
+ const hintId = `${id}-wordcount-hint`;
+
+ let hintText = '';
+
+ const wordCountText = count => count === 1 ? 'word' : 'words';
+
+ if (wordCount > maxWordCount) {
+ const count = wordCount - maxWordCount;
+ hintText = `You have ${count} ${wordCountText(count)} too many`;
+ } else {
+ const count = maxWordCount - wordCount;
+ hintText = `You have ${count} ${wordCountText(count)} remaining`;
+ }
+
+ return (
+
+ {hintText}
+
+ );
+};
+
+export default WordCountHintMessage;
diff --git a/styles/index.scss b/styles/index.scss
index e345aae5..b6beaaa5 100644
--- a/styles/index.scss
+++ b/styles/index.scss
@@ -44,6 +44,7 @@ $highlight-colour: govuk-colour('grey-4');
@import '../src/sticky-nav-anchor/index';
@import '../src/tabs/index';
@import '../src/licence-status-banner/index';
+@import '../src/text-area-word-count/index';
.hidden {
display: none;