Skip to content

Commit 0533db3

Browse files
Merge pull request #1897 from nhsuk/character-count-custom-function
[v10] Add character count `countFunction` option
2 parents 50d437b + 7b1a233 commit 0533db3

3 files changed

Lines changed: 94 additions & 26 deletions

File tree

eslint.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ export default defineConfig([
199199
// https://browsersl.ist/#q=supports+es6-module+and+not+supports+array-includes
200200
'es-x/no-array-prototype-includes': 'off',
201201

202+
// Babel transpiles ES2021 `??=` logical assignment
203+
'es-x/no-logical-assignment-operators': 'off',
204+
202205
// Babel transpiles ES2020 `??` nullish coalescing
203206
'es-x/no-nullish-coalescing-operators': 'off',
204207

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,40 @@ describe('Character count', () => {
428428

429429
expect(component.getCountMessage()).toBe('You have 97 words remaining')
430430
})
431+
432+
it('uses custom `countFunction` for `maxlength` limit when set', async () => {
433+
const component = new CharacterCount($root, {
434+
maxlength: 100,
435+
countFunction: jest.fn().mockReturnValue(10)
436+
})
437+
438+
$textarea.focus()
439+
await user.keyboard('Newly updated value')
440+
441+
expect(component.config.countFunction).toHaveBeenLastCalledWith(
442+
'Newly updated value'
443+
)
444+
445+
expect(component.getCountMessage()).toBe(
446+
'You have 90 characters remaining'
447+
)
448+
})
449+
450+
it('uses custom `countFunction` for `maxwords` limit when set', async () => {
451+
const component = new CharacterCount($root, {
452+
maxwords: 100,
453+
countFunction: jest.fn().mockReturnValue(10)
454+
})
455+
456+
$textarea.focus()
457+
await user.keyboard('Newly updated value')
458+
459+
expect(component.config.countFunction).toHaveBeenLastCalledWith(
460+
'Newly updated value'
461+
)
462+
463+
expect(component.getCountMessage()).toBe('You have 90 words remaining')
464+
})
431465
})
432466

433467
describe('with HTML lang attribute', () => {

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

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export class CharacterCount extends ConfigurableComponent {
6161
const {
6262
i18n,
6363
maxlength,
64+
countFunction,
6465
countType,
6566
screenReaderCountMessageClass,
6667
textareaDescriptionClass,
@@ -72,7 +73,7 @@ export class CharacterCount extends ConfigurableComponent {
7273
locale: closestAttributeValue(this.$root, 'lang')
7374
})
7475

75-
if (countType === 'characters') {
76+
if (countType === 'characters' || !!countFunction) {
7677
if (!('Segmenter' in Intl)) {
7778
throw new SupportError(
7879
formatErrorMessage(
@@ -83,7 +84,7 @@ export class CharacterCount extends ConfigurableComponent {
8384
}
8485

8586
this.segmenter = new Intl.Segmenter(this.i18n.locale, {
86-
granularity: 'grapheme'
87+
granularity: countType === 'words' ? 'word' : 'grapheme'
8788
})
8889
}
8990

@@ -220,31 +221,12 @@ export class CharacterCount extends ConfigurableComponent {
220221
* @param {string} [text] - Deprecated
221222
*/
222223
updateCount(text) {
223-
const { $textarea } = this
224-
const { countType } = this.config
224+
const { $textarea, countFunctions } = this
225+
let { countType, countFunction } = this.config
225226

226-
text = text ?? $textarea.value
227-
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
239-
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-
}
227+
text ??= $textarea.value
228+
countFunction ??= countFunctions[countType]
229+
this.length = countFunction.call(this, text)
248230
}
249231

250232
/**
@@ -467,6 +449,46 @@ export class CharacterCount extends ConfigurableComponent {
467449
}
468450
}
469451

452+
/**
453+
* Character count functions
454+
*
455+
* @constant
456+
* @satisfies {Record<string, CharacterCountConfig['countFunction']>}
457+
*/
458+
countFunctions = Object.freeze({
459+
/**
460+
* Count code points (string length)
461+
*
462+
* @param {string} text - Textarea value
463+
* @returns {number} Count
464+
*/
465+
length(text) {
466+
return text.length
467+
},
468+
469+
/**
470+
* Count grapheme clusters (user-perceived characters)
471+
*
472+
* @param {string} text - Textarea value
473+
* @returns {number} Count
474+
*/
475+
characters(text) {
476+
return this.segmenter
477+
? Array.from(this.segmenter.segment(text)).length
478+
: 0
479+
},
480+
481+
/**
482+
* Count consecutive non-whitespace results
483+
*
484+
* @param {string} text - Textarea value
485+
* @returns {number} Count
486+
*/
487+
words(text) {
488+
return text.match(/\S+/g)?.length ?? 0
489+
}
490+
})
491+
470492
/**
471493
* Name for the component used when initialising using data-module attributes
472494
*/
@@ -524,6 +546,7 @@ export class CharacterCount extends ConfigurableComponent {
524546
maxlength: { type: 'number' },
525547
threshold: { type: 'number' },
526548
countType: { type: 'string' },
549+
countFunction: { type: 'function' },
527550
textareaDescriptionClass: { type: 'string' },
528551
visibleCountMessageClass: { type: 'string' },
529552
screenReaderCountMessageClass: { type: 'string' },
@@ -574,12 +597,20 @@ export function initCharacterCounts(options) {
574597
* count message will be hidden by default.
575598
* @property {'characters' | 'length' | 'words'} countType - The count type
576599
* (`"characters"`, `"length"` or `"words"`) used to count the text.
600+
* @property {CharacterCountFunction} [countFunction] - Custom character or
601+
* word counting function.
577602
* @property {string} textareaDescriptionClass - Textarea description class
578603
* @property {string} visibleCountMessageClass - Visible count message class
579604
* @property {string} screenReaderCountMessageClass - Screen reader count message class
580605
* @property {CharacterCountTranslations} [i18n=CharacterCount.defaults.i18n] - Character count translations
581606
*/
582607

608+
/**
609+
* Character count function
610+
*
611+
* @typedef {(this: CharacterCount, text: string) => number} CharacterCountFunction
612+
*/
613+
583614
/**
584615
* Character count translations
585616
*

0 commit comments

Comments
 (0)