Skip to content

Commit 0cea3dd

Browse files
Merge pull request #1899 from nhsuk/character-count-word-segmenter
[v10] Add character count `countType: "words"` option using Intl.Segmenter
2 parents ec978bd + 9e0feef commit 0cea3dd

10 files changed

Lines changed: 294 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,111 @@ If you are not using Nunjucks macros, use the HTML markup from the [search input
2626

2727
This change was introduced in [pull request #1660: Add search input component](https://github.com/nhsuk/nhsuk-frontend/pull/1660).
2828

29+
#### Improved character count counting
30+
31+
We've added a new `countType` option to the character count component to enable [improved counting with `Intl.Segmenter`](https://developer.mozilla.org/en-US/blog/javascript-intl-segmenter-i18n/).
32+
33+
This feature was introduced because [JavaScript counts `String: length` in code units](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length) not characters, for example:
34+
35+
| String | Length | Remarks |
36+
| ------ | ------ | -------------------------------------------------------------------------- |
37+
| cafȩ́ | 5 | The character `ȩ́` counted as 2 code units |
38+
| cafȩ́ | 5 | The character `ȩ` with combining mark ` ́` counted as 2 code units |
39+
| cafȩ́ | 6 | The character `e` with combining marks ` ́` and ` ̧` counted as 3 code units |
40+
| 😹 | 2 | The cat emoji counted as 2 code units |
41+
| 👩🏻‍🚀 | 7 | The astronaut emoji with gender and skin modifiers counted as 7 code units |
42+
43+
Similarly when counting words, "my mother-in-law" is now counted as 4 (not 2) words to correctly follow the [Unicode **Default Word Boundary Specification**](https://unicode.org/reports/tr29/#Default_Word_Boundaries).
44+
45+
To enable improved counting in [supported browsers](https://caniuse.com/wf-intl-segmenter) you should either:
46+
47+
- add `countType: "characters"` to count user-perceived characters
48+
- add `countType: "words"` to count words between word boundaries
49+
50+
Unsupported browsers will default to the `textareaDescriptionText` message shown when JavaScript is unavailable, such as:
51+
52+
> You can enter up to 350 characters
53+
54+
When using Nunjucks to count characters:
55+
56+
```patch
57+
{{ characterCount({
58+
label: {
59+
text: "Can you provide more detail?",
60+
size: "l",
61+
isPageHeading: true
62+
},
63+
name: "more-detail",
64+
- maxlength: 350
65+
+ maxlength: 350,
66+
+ countType: "characters"
67+
}) }}
68+
```
69+
70+
Or when using Nunjucks to count words:
71+
72+
```patch
73+
{{ characterCount({
74+
label: {
75+
text: "Can you provide more detail?",
76+
size: "l",
77+
isPageHeading: true
78+
},
79+
name: "more-detail",
80+
- maxwords: 150
81+
+ maxlength: 150,
82+
+ countType: "words"
83+
}) }}
84+
```
85+
86+
Note: [The character count `maxwords` option and word counting behaviour are deprecated](#rename-the-character-count-maxwords-option) and will be removed in a future release. You must replace `maxwords` with `maxlength` when using `countType: "words"`.
87+
88+
This was added in pull requests [#1892: Refactor character count method to reduce repeated updates](https://github.com/nhsuk/nhsuk-frontend/pull/1892), [#1893: Deprecate character count `maxwords` and add `countType` option](https://github.com/nhsuk/nhsuk-frontend/pull/1893), [#1895: Add character count `countType: "characters"` option using Intl.Segmenter](https://github.com/nhsuk/nhsuk-frontend/pull/1895) and [#1899: Add character count `countType: "words"` option using Intl.Segmenter](https://github.com/nhsuk/nhsuk-frontend/pull/1899).
89+
90+
#### Custom character count functions
91+
92+
We've added a new `countFunction` option to the character count component.
93+
94+
Service teams can now cater for server-side differences in:
95+
96+
- New lines that vary due to `\n` versus `\r\n`
97+
- Word counts that vary based on empty space and punctuation
98+
- How empty space is trimmed before counting
99+
- Support for multi-byte strings
100+
101+
For example, services might already count multi-byte strings server-side (e.g. [`len()` in Python](https://docs.python.org/3.9/library/functions.html?highlight=len#len)) resulting in client-side count mismatches, yet [support for improved character count counting](#improved-character-count-counting) may be blocked by a [3rd party library integration](https://grapheme.readthedocs.io/en/latest/grapheme.html).
102+
103+
Custom count functions are called with:
104+
105+
- `text` (string) - Textarea value
106+
- `context` (object) - Character count context
107+
108+
```mjs
109+
new CharacterCount($root, {
110+
maxlength: 350,
111+
countType: 'characters',
112+
countFunction(text, context) {
113+
return text.length
114+
}
115+
})
116+
```
117+
118+
Character count `context` objects contain the following properties:
119+
120+
- `$textarea` - Textarea HTML element
121+
- `config` - Character count config
122+
- `segmenter` - Character count `Intl.Segmenter` (optional)
123+
124+
Our built in count functions are available to call or extend via:
125+
126+
```mjs
127+
CharacterCount.countFunctions.length
128+
CharacterCount.countFunctions.characters
129+
CharacterCount.countFunctions.words
130+
```
131+
132+
This was added in [pull request #1897: Add character count `countFunction` option](https://github.com/nhsuk/nhsuk-frontend/pull/1897).
133+
29134
#### Add date input `day`, `month` and `year` Nunjucks options
30135

31136
We've updated the date input component to add individual `day`, `month` and `year` Nunjucks options.
@@ -218,7 +323,43 @@ This was added in [pull request #1937: Add a modifier class for inline checkboxe
218323

219324
If you are using our Nunjucks [page template](https://service-manual.nhs.uk/design-system/styles/page-template), you can now extend `template-with-imports.njk` instead of `template.njk` to automatically import all components.
220325

221-
This was was added in [pull request #1921: Add a template with all components imported](https://github.com/nhsuk/nhsuk-frontend/pull/1921).
326+
This was added in [pull request #1921: Add a template with all components imported](https://github.com/nhsuk/nhsuk-frontend/pull/1921).
327+
328+
### :wastebasket: **Deprecated features**
329+
330+
#### Rename the character count `maxwords` option
331+
332+
To support improved word counting using the browser [`Intl.Segmenter` API](https://developer.mozilla.org/en-US/blog/javascript-intl-segmenter-i18n/), you should replace the character count `maxwords` option with `maxlength` and set `countType: "words"`.
333+
334+
For example, using Nunjucks:
335+
336+
```patch
337+
{{ characterCount({
338+
label: {
339+
text: "Can you provide more detail?",
340+
size: "l",
341+
isPageHeading: true
342+
},
343+
name: "more-detail",
344+
- maxwords: 150
345+
+ maxlength: 150,
346+
+ countType: "words"
347+
}) }}
348+
```
349+
350+
Or when using the JavaScript API:
351+
352+
```patch
353+
new CharacterCount($root, {
354+
- maxwords: 150
355+
+ maxlength: 150,
356+
+ countType: 'words'
357+
})
358+
```
359+
360+
The previous `maxwords` option and word counting behaviour are deprecated and will be removed in a future release.
361+
362+
This was added in pull requests [#1893: Deprecate character count `maxwords` and add `countType` option](https://github.com/nhsuk/nhsuk-frontend/pull/1893) and [#1899: Add character count `countType: "words"` option using Intl.Segmenter](https://github.com/nhsuk/nhsuk-frontend/pull/1899).
222363

223364
### :recycle: **Changes**
224365

docs/configuration/javascript-api-reference.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,43 @@ Default:
7171
'length'
7272
```
7373

74+
### countFunction
75+
76+
Type: function
77+
78+
The count function used to count the text. Returns the count as a number.
79+
80+
Default:
81+
82+
```mjs
83+
CharacterCount.countFunctions.length
84+
```
85+
86+
Count functions are called with:
87+
88+
- `text` (string) - Textarea value
89+
- `context` (object) - Character count context
90+
91+
```mjs
92+
(text, context) => {
93+
return text.length
94+
}
95+
```
96+
97+
Character count `context` objects contain the following properties:
98+
99+
- `$textarea` - Textarea HTML element
100+
- `config` - Character count config
101+
- `segmenter` - Character count `Intl.Segmenter` (optional)
102+
103+
Our built in count functions are available to call or extend via:
104+
105+
```mjs
106+
CharacterCount.countFunctions.length
107+
CharacterCount.countFunctions.characters
108+
CharacterCount.countFunctions.words
109+
```
110+
74111
### textareaDescriptionClass
75112

76113
Type: string

eslint.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ export default defineConfig([
174174
}
175175
],
176176

177+
// Allow examples to contain unused expressions
178+
'@typescript-eslint/no-unused-expressions': 'off',
179+
177180
// Check type support for template string implicit `.toString()`
178181
'@typescript-eslint/restrict-template-expressions': [
179182
'error',

packages/nhsuk-frontend/src/nhsuk/components/character-count/character-count.jsdom.test.mjs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ describe('Character count', () => {
133133
)
134134
})
135135

136+
it('should not throw without Intl.Segmenter support when count function is provided', () => {
137+
// @ts-expect-error The operand of a 'delete' operator cannot be a read-only property
138+
delete Intl.Segmenter
139+
140+
expect(() => {
141+
new CharacterCount($root, {
142+
countType: 'characters',
143+
countFunction: jest.fn()
144+
})
145+
}).not.toThrow()
146+
})
147+
136148
it('should throw with missing $root element', () => {
137149
// @ts-expect-error Parameter '$root' not provided
138150
expect(() => new CharacterCount()).toThrow(
@@ -439,7 +451,11 @@ describe('Character count', () => {
439451
await user.keyboard('Newly updated value')
440452

441453
expect(component.config.countFunction).toHaveBeenLastCalledWith(
442-
'Newly updated value'
454+
'Newly updated value',
455+
{
456+
config: component.config,
457+
segmenter: component.segmenter
458+
}
443459
)
444460

445461
expect(component.getCountMessage()).toBe(
@@ -457,7 +473,11 @@ describe('Character count', () => {
457473
await user.keyboard('Newly updated value')
458474

459475
expect(component.config.countFunction).toHaveBeenLastCalledWith(
460-
'Newly updated value'
476+
'Newly updated value',
477+
{
478+
config: component.config,
479+
segmenter: component.segmenter
480+
}
461481
)
462482

463483
expect(component.getCountMessage()).toBe('You have 90 words remaining')

0 commit comments

Comments
 (0)