Skip to content

Commit 50d437b

Browse files
Merge pull request #1895 from nhsuk/character-count-grapheme
[v10] Add character count `countType: "characters"` option using Intl.Segmenter
2 parents 5c7319b + 1da2682 commit 50d437b

9 files changed

Lines changed: 472 additions & 41 deletions

docs/configuration/javascript-api-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Default:
6363

6464
Type: string
6565

66-
The count type (`'length'` or `'words'`) used to count the text.
66+
The count type (`'length'`, `'characters'` or `'words'`) used to count the text.
6767

6868
Default:
6969

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

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { getByRole } from '@testing-library/dom'
2+
import { userEvent } from '@testing-library/user-event'
23
import { outdent } from 'outdent'
34

45
import { components } from '#lib'
56

67
import { CharacterCount, initCharacterCounts } from './character-count.mjs'
78
import { examples } from './fixtures.mjs'
89

10+
const user = userEvent.setup()
11+
912
describe('Character count', () => {
1013
/** @type {HTMLElement} */
1114
let $root
@@ -94,6 +97,17 @@ describe('Character count', () => {
9497
})
9598

9699
describe('Initialisation via class', () => {
100+
/** @type {typeof Intl.Segmenter} */
101+
let Segmenter
102+
103+
beforeEach(() => {
104+
Segmenter = Intl.Segmenter
105+
})
106+
107+
afterEach(() => {
108+
Object.assign(Intl, { Segmenter })
109+
})
110+
97111
it('should not throw with $root element', () => {
98112
expect(() => new CharacterCount($root)).not.toThrow()
99113
})
@@ -106,6 +120,19 @@ describe('Character count', () => {
106120
)
107121
})
108122

123+
it('should throw without Intl.Segmenter support', () => {
124+
// @ts-expect-error The operand of a 'delete' operator cannot be a read-only property
125+
delete Intl.Segmenter
126+
127+
expect(() => {
128+
new CharacterCount($root, {
129+
countType: 'characters'
130+
})
131+
}).toThrow(
132+
`${CharacterCount.moduleName}: Support for "Intl.Segmenter" required`
133+
)
134+
})
135+
109136
it('should throw with missing $root element', () => {
110137
// @ts-expect-error Parameter '$root' not provided
111138
expect(() => new CharacterCount()).toThrow(
@@ -164,8 +191,9 @@ describe('Character count', () => {
164191
expect(component.updateIfValueChanged).toHaveBeenCalled()
165192
})
166193

167-
it('should handle deprecated params', () => {
168-
$textarea.value = 'Existing value'
194+
it('should handle deprecated params', async () => {
195+
await user.click($textarea)
196+
await user.keyboard('Existing value')
169197

170198
const component = new CharacterCount($root)
171199

@@ -234,13 +262,25 @@ describe('Character count', () => {
234262
})
235263
})
236264

265+
it('configures `countType: "characters"`', () => {
266+
initExample("with count type 'characters'")
267+
268+
const characterCount = new CharacterCount($root)
269+
expect(characterCount.config).toEqual({
270+
...CharacterCount.defaults,
271+
maxlength: 200,
272+
threshold: 0,
273+
countType: 'characters'
274+
})
275+
})
276+
237277
it('configures `countType: "words"`', () => {
238278
initExample("with count type 'words'")
239279

240280
const characterCount = new CharacterCount($root)
241281
expect(characterCount.config).toEqual({
242282
...CharacterCount.defaults,
243-
maxlength: 150,
283+
maxlength: 50,
244284
threshold: 0,
245285
countType: 'words'
246286
})
@@ -327,8 +367,9 @@ describe('Character count', () => {
327367
expect(component.formatCountMessage(0)).toBe('Different custom text.')
328368
})
329369

330-
it('uses existing textarea value for `maxlength` limit when initialised', () => {
331-
$textarea.value = 'Existing value'
370+
it('uses existing textarea value for `maxlength` limit when initialised', async () => {
371+
await user.click($textarea)
372+
await user.keyboard('Existing value')
332373

333374
const component = new CharacterCount($root, {
334375
maxlength: 100
@@ -339,8 +380,9 @@ describe('Character count', () => {
339380
)
340381
})
341382

342-
it('uses existing textarea value for `maxwords` limit when initialised', () => {
343-
$textarea.value = 'Existing value'
383+
it('uses existing textarea value for `maxwords` limit when initialised', async () => {
384+
await user.click($textarea)
385+
await user.keyboard('Existing value')
344386

345387
const component = new CharacterCount($root, {
346388
maxwords: 100
@@ -349,12 +391,13 @@ describe('Character count', () => {
349391
expect(component.getCountMessage()).toBe('You have 98 words remaining')
350392
})
351393

352-
it('uses current textarea value for `maxlength` limit via back/forward navigation', () => {
394+
it('uses current textarea value for `maxlength` limit via back/forward navigation', async () => {
353395
const component = new CharacterCount($root, {
354396
maxlength: 100
355397
})
356398

357-
$textarea.value = 'Newly updated value'
399+
await user.click($textarea)
400+
await user.keyboard('Newly updated value')
358401

359402
// Trigger back/forward navigation
360403
window.dispatchEvent(
@@ -368,12 +411,13 @@ describe('Character count', () => {
368411
)
369412
})
370413

371-
it('uses current textarea value for `maxwords` limit via back/forward navigation', () => {
414+
it('uses current textarea value for `maxwords` limit via back/forward navigation', async () => {
372415
const component = new CharacterCount($root, {
373416
maxwords: 100
374417
})
375418

376-
$textarea.value = 'Newly updated value'
419+
await user.click($textarea)
420+
await user.keyboard('Newly updated value')
377421

378422
// Trigger back/forward navigation
379423
window.dispatchEvent(

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

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { closestAttributeValue } from '../../common/closest-attribute-value.mjs'
22
import { normaliseOptions } from '../../common/configuration/index.mjs'
33
import { formatErrorMessage } from '../../common/index.mjs'
44
import { ConfigurableComponent } from '../../configurable-component.mjs'
5-
import { ElementError } from '../../errors/index.mjs'
5+
import { ElementError, SupportError } from '../../errors/index.mjs'
66
import { I18n } from '../../i18n.mjs'
77

88
/**
@@ -20,6 +20,11 @@ import { I18n } from '../../i18n.mjs'
2020
export class CharacterCount extends ConfigurableComponent {
2121
length = 0
2222

23+
/**
24+
* @type {Intl.Segmenter | null}
25+
*/
26+
segmenter = null
27+
2328
/**
2429
* @type {number | null}
2530
*/
@@ -56,6 +61,7 @@ export class CharacterCount extends ConfigurableComponent {
5661
const {
5762
i18n,
5863
maxlength,
64+
countType,
5965
screenReaderCountMessageClass,
6066
textareaDescriptionClass,
6167
visibleCountMessageClass
@@ -66,6 +72,21 @@ export class CharacterCount extends ConfigurableComponent {
6672
locale: closestAttributeValue(this.$root, 'lang')
6773
})
6874

75+
if (countType === 'characters') {
76+
if (!('Segmenter' in Intl)) {
77+
throw new SupportError(
78+
formatErrorMessage(
79+
CharacterCount,
80+
'Support for "Intl.Segmenter" required'
81+
)
82+
)
83+
}
84+
85+
this.segmenter = new Intl.Segmenter(this.i18n.locale, {
86+
granularity: 'grapheme'
87+
})
88+
}
89+
6990
// Determine the limit attribute (characters or words)
7091
this.maxLength = maxlength ?? Infinity
7192

@@ -204,13 +225,26 @@ export class CharacterCount extends ConfigurableComponent {
204225

205226
text = text ?? $textarea.value
206227

207-
if (countType === 'words') {
208-
const tokens = text.match(/\S+/g) ?? [] // Matches consecutive non-whitespace chars
209-
this.length = tokens.length
210-
return
211-
}
228+
switch (countType) {
229+
case 'length':
230+
// Count code points (string length)
231+
this.length = text.length
232+
break
233+
234+
case 'characters': {
235+
// Count grapheme clusters (user-perceived characters)
236+
this.length = this.segmenter
237+
? Array.from(this.segmenter.segment(text)).length
238+
: 0
212239

213-
this.length = text.length
240+
break
241+
}
242+
243+
case 'words':
244+
// Count consecutive non-whitespace results
245+
this.length = text.match(/\S+/g)?.length ?? 0
246+
break
247+
}
214248
}
215249

216250
/**
@@ -538,8 +572,8 @@ export function initCharacterCounts(options) {
538572
* @property {number} [threshold=0] - The percentage value of the limit at
539573
* which point the count message is displayed. If this attribute is set, the
540574
* count message will be hidden by default.
541-
* @property {'length' | 'words'} countType - The count type (`"length"` or
542-
* `"words"`) used to count the text.
575+
* @property {'characters' | 'length' | 'words'} countType - The count type
576+
* (`"characters"`, `"length"` or `"words"`) used to count the text.
543577
* @property {string} textareaDescriptionClass - Textarea description class
544578
* @property {string} visibleCountMessageClass - Visible count message class
545579
* @property {string} screenReaderCountMessageClass - Screen reader count message class

0 commit comments

Comments
 (0)