Skip to content

Add character count Intl.Segmenter support#6995

Open
colinrotherham wants to merge 13 commits into
alphagov:mainfrom
colinrotherham:character-count-segmenter
Open

Add character count Intl.Segmenter support#6995
colinrotherham wants to merge 13 commits into
alphagov:mainfrom
colinrotherham:character-count-segmenter

Conversation

@colinrotherham

@colinrotherham colinrotherham commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

This PR updates the character count component to (optionally) use Intl.Segmenter

It closes #1104, #1364 and partly #2888

Improved character count counting

I've added an optional countType option to the character count component to enable improved counting with Intl.Segmenter whilst maintaining backwards compatibility.

This feature was introduced because JavaScript counts String: length in code units not characters:

String Length Remarks
cafȩ́ 5 The character ȩ́ counted as 2 code units
cafȩ́ 5 The character ȩ with combining mark ́ counted as 2 code units
cafȩ́ 6 The character e with combining marks ́ and ̧ counted as 3 code units
😹 2 The cat emoji counted as 2 code units
👩🏻‍🚀 7 The astronaut emoji with gender and skin modifiers counted as 7 code units

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.

To enable improved counting in supported browsers users can:

  • add countType: "characters" to count user-perceived characters (or graphemes)
  • add countType: "words" to count words between word boundaries

Where the default is countType: "length" to continue counting code points.

Unsupported browsers will default to the textareaDescriptionText message shown when JS is unavailable:

You can enter up to 350 characters

Using Nunjucks to count characters

Adding countType: "characters" to count using Intl.Segmenter

  {{ govukCharacterCount({
    label: {
      text: "Can you provide more detail?",
      size: "l",
      isPageHeading: true
    },
    name: "more-detail",
-   maxlength: 350
+   maxlength: 350,
+   countType: "characters"
  }) }}

Using Nunjucks to count words

Adding countType: "words" to count using Intl.Segmenter

  {{ govukCharacterCount({
    label: {
      text: "Can you provide more detail?",
      size: "l",
      isPageHeading: true
    },
    name: "more-detail",
-   maxwords: 150
+   maxlength: 150,
+   countType: "words"
  }) }}

Note: The character count maxwords option and word counting behaviour are deprecated and can be removed in a future release. Users should replace maxwords with maxlength when using countType: "words".

Test coverage

I've skipped on tests until you're happy with the proposal (and comments) in:

This feature was lifted from NHS.UK frontend so we can bring test coverage over from:

With some related configuration changes split out into:


A previous version of this PR supported the countFunction option with my notes preserved below

It's now been removed whilst the team discuss potentially overloading countType instead

Customising the count function

I've also added an extra countFunction option so service teams can cater for server-side differences in:

  • New lines that vary due to \n versus \r\n
  • Word counts that vary based on empty space and punctuation
  • How empty space is trimmed before counting
  • Support for multi-byte strings

For example, services might already count multi-byte strings server-side (e.g. len() in Python) resulting in client-side count mismatches, yet support for improved character count counting may be blocked by a 3rd party library integration.

Custom count functions are called with:

  • text (string) - Textarea value
  • context (object) - Character count context
new CharacterCount($root, {
  maxlength: 350,
  countType: 'characters',
  countFunction(text, context) {
    return text.length
  }
})

Character count context objects contain the following properties:

  • config - Character count config
  • segmenter - Character count Intl.Segmenter (optional)

With the built in count functions available to call or extend via:

CharacterCount.countFunctions.length
CharacterCount.countFunctions.characters
CharacterCount.countFunctions.words

Determining the count type

I investigated a hybrid countFunction: "characters" option with (optional) count function overload.

But the character count relies on maxlength or maxwords to determine countType internally:

Overloading countFunction: "characters" with a function prevents this.

i.e. Given countFunction: (text, context) => {} how do we determine the count type?

With backwards compatibilty in mind, knowledge of the count type is necessary to pick between:

i18n.charactersUnderLimit
i18n.charactersAtLimit
i18n.charactersOverLimit
i18n.wordsUnderLimit
i18n.wordsAtLimit
i18n.wordsOverLimit

With the current component throwing a ConfigError when the count type cannot be determined:

'govuk-character-count: Either "maxlength" or "maxwords" must be provided'

Relatedly, neither GOV.UK Function schema nor Design System JSON parsing support multiple types.

At the moment, config schema types support single types (e.g. 'string') only:

static schema = Object.freeze({
  properties: {
    // … 
    countType: { type: 'string' },
    countFunction: { type: 'function' }

Although work to add a new type: 'function' property was straightforward, the investigation above shows why it's not possible to combine countType and countFunction into one.

Similarly for security reasons, it's very important to drop unsupported types, e.g. functions in data attributes

Comment on lines +36 to +40
/**
* @private
* @type {Intl.Segmenter | null}
*/
segmenter = null

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure whether this should be @private?

For example this.segmenter can be accessed via the custom count function:

createAll(CharacterCount, {
  countFunction(text) {
    // this.segmenter
  }
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saw that lingering comment that we didn't address. Given we're not having countFunction (and even in that situation, the segmenter being in the context) this should remain @private for now :)

@36degrees 36degrees left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't had a chance to do a full review, but my gut feeling is that we should avoid scenarios where the character count can give a different result depending on what browser you're using.

That being the case, in browsers that do not support Intl.Segementer I think we should fall back to the no-JS behaviour, rather than using a regex.

@colinrotherham

Copy link
Copy Markdown
Contributor Author

Thanks @36degrees

Did you have any thoughts on the new countType Nunjucks option?

I haven't had a chance to do a full review, but my gut feeling is that we should avoid scenarios where the character count can give a different result depending on what browser you're using.

That makes sense, and means we can drop the fallback regexes too

For balance, there are some examples where browser differences are expected:

But for the latter issue polyfill weight was involved

That being the case, in browsers that do not support Intl.Segementer I think we should fall back to the no-JS behaviour, rather than using a regex.

We're happy with this though and I can update the PR

@colinrotherham colinrotherham force-pushed the character-count-segmenter branch from 66b3afb to 405410f Compare May 11, 2026 14:11
@colinrotherham

Copy link
Copy Markdown
Contributor Author

Pushed an update to do this:

That being the case, in browsers that do not support Intl.Segementer I think we should fall back to the no-JS behaviour, rather than using a regex.

  • Current options maxlength and maxwords work as usual for backwards compatibility
  • New options countType: "characters" or countType: "words" use Intl.Segmenter

@romaricpascal romaricpascal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cheers for proposing this @colinrotherham and the care around avoiding a breaking change 🙌🏻

Besides the comments about technical implementation, I'm concerned about a two things:

  1. keeping maxlength as the source for the maximum for both characters may be a bit confusing, both to users used to it being used only for characters and when switching codebases using different versions of GOV.UK Frontend. I'd be keen to use a completely new option (say maximum) that'll be associated to the new countType to avoid confusion
  2. only offering to count words the way Intl.Segmenter does with the countType option that the component is moving towards. I think we should check which way backends count words to make sure our default matches. It might be that we need two ways of counting: one counting like Intl.Segmenter and another only considering the whitespace as we do now, even if it does not match Unicode definition of a word.

Let me know what you think 😊

Comment thread packages/govuk-frontend/src/govuk/common/configuration.mjs Outdated
Comment thread packages/govuk-frontend/src/govuk/init.mjs
Comment thread packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs Outdated
this.count = text.match(/\S+/g)?.length ?? 0
break
}
this.count = this.countFunctions[countType].call(this, text)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion Rather than executing the function like if it was a method of the CharacterCount, passing the segmenter as a second argument, inside an options object makes the boundary between the component and the count function clearer, allowing us to control what's exposed to the count function.

Suggested change
this.count = this.countFunctions[countType].call(this, text)
this.count = this.countFunctions[countType](text, {segmenter: this.segmenter})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could use a getter to only instantiate the segmenter if the function accesses it, but that's more an optimisation than anything.

@colinrotherham colinrotherham May 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly that means the custom countFunction would lose access to:

  • this.separator to split words yourself
  • this.segmenter to filter the segments yourself
  • this.$textarea to get the value yourself (e.g. trim, normalised line endings, row count etc)

Appreciate that all of these things are accessible anyway as @private isn't really private

Let me know if you'd like me to do anything

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm fine witht the countFunction not having access to much at the start. We can always expand what we provide to the function in minor releases, but we can only restrict what the function receives in breaking releases if we went too far at the start.

Using an object as a second parameter would also clarify what this represents in the component's count functions (where you may think it's the countFunctions object where the functions are defined if you miss the typings).

Overall, if that's OK, I'd prefer we:

  • pass a second argument to the count function rather than use this (should have flagged that as an 'issue' rather than a 'suggestion')
  • restrict what the function receives to only the segmenter for now and expand as demand grows, keeping the separator in the function for counting words (thinking that long term, if we want people to manipulate texts before counting 'like the component does', we'd be better off exposing the countFunctions themselves rather than granular details of their implementation).

Hope that makes sense 😊

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a second parameter I've set a custom (restricted) this and updated the types:

- this.count = countFunction.call(this, text)
+ this.count = countFunction.call({ segmenter: this.segmenter }, text)

Have a look at the diff for my last push to see this.separator has been removed too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid recreating the count function context object every time, it could be persisted?

Either using .call() as this

  // Limit access via `this` when calling the count function to prevent
  // unintended access to internal properties and methods
- this.count = countFunction.call(
-   {
-     config: this.config,
-     segmenter: this.segmenter
-   },
-   text
- )
+ this.count = countFunction.call(this.countFunctionContext, text)

Or as a 2nd param as you prefer:

  // Limit access via `this` when calling the count function to prevent
  // unintended access to internal properties and methods
- this.count = countFunction.call(
-   {
-     config: this.config,
-     segmenter: this.segmenter
-   },
-   text
- )
+ this.count = countFunction(text, this.countFunctionContext)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would definitely prefer a context as a parameter so that things are explicit rather than accessed through this, thanks. Feels more natural as second parameter. Not against caching it on the instance if you think that's an issue for performance to re-create it at each call.

Another thought that occured to me is that if this.config.maxwords is set, there is no this.segmenter, right? This means that the words function could branch on whether this.segmenter is definer rather than the value in the config. That would allow the public API for the context to be narrower.

Potentially, the characters function could work the same way for consistency. That would also clearly split which part of the component are responsible for what:

  • constructor decides whether to create a segmenter or not based on the config
  • count functions decide how to count based on whether they have a segmenter or not

How does that sound?

@colinrotherham colinrotherham May 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good on the 2nd parameter

I'm a little bit lost on the rest 😆

Another thought that occured to me is that if this.config.maxwords is set, there is no this.segmenter, right? This means that the words function could branch on whether this.segmenter is definer rather than the value in the config. That would allow the public API for the context to be narrower.

So this is what I did originally—I think?

Where branching on this.segmenter for countType: "words" gave different results by browser

But it differs to the feelings Ollie set in an earlier comment where he said:

I haven't had a chance to do a full review, but my gut feeling is that we should avoid scenarios where the character count can give a different result depending on what browser you're using.

That being the case, in browsers that do not support Intl.Segementer I think we should fall back to the no-JS behaviour, rather than using a regex.

So from this we've determined:

  • Users that set maxwords (deprecated) should the existing regex word count
  • Users that set countType: "words" should get segmenter word counting where supported
  • Users that set countType: "words" should get the no-JS behaviour where NOT supported

i.e. If you opt-in to use Intl.Segmenter then that's what you get (or the no-JS behaviour)

Hope that's still alright?


Regarding browser support

Knowing that Intl.Segmenter is in Baseline 2024 compare the following queries:

Note: There sadly isn't a feature query intl-segmenter like there is for intl-pluralrules

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would definitely prefer a context as a parameter so that things are explicit rather than accessed through this, thanks. Feels more natural as second parameter. Not against caching it on the instance if you think that's an issue for performance to re-create it at each call.

✅ Done (pushed)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is what I did originally—I think?

Where branching on this.segmenter for countType: "words" gave different results by browser

But it differs to the feelings Ollie set in an earlier comment where he said:

I think I didn't explain well. Found it easier to attach a comment to the countFunctionContext to explain 😊

Comment thread packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs Outdated
@colinrotherham

colinrotherham commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @romaricpascal

You might have missed that word counting retains the current approach if maxwords is set 🙌

Regarding maximum versus using maxlength, wouldn't the latter mean zero changes are necessary should segmenter become the default in a future major release?

✅ GOV.UK Frontend v2.2.0+

The maxlength option always works

{{ govukCharacterCount({
  label: {
    text: "Always works"
  },
  name: "example",
  maxlength: 200
}) }}

@colinrotherham

Copy link
Copy Markdown
Contributor Author

Do think about a future opt-out though, like segmenter: false?

Or having the word separator regex as an option? If set, bypassing the segmenter

Keen to lock in the API so we can release this on NHS.UK frontend

@colinrotherham colinrotherham force-pushed the character-count-segmenter branch 2 times, most recently from 579f202 to 29c44bf Compare May 14, 2026 11:42
@romaricpascal

Copy link
Copy Markdown
Member

Regarding maximum versus using maxlength, wouldn't the latter mean zero changes are necessary should segmenter become the default in a future major release?

That's a great point, hadn't thought of that. 🙌🏻

You might have missed that word counting retains the current approach if maxwords is set 🙌

My worry was for after we remove maxwords in the next major release (as it's being rightly deprecated in this PR).
Both your propositions of a separator and a segmenter: false opt-out would be a way to work around that, so I think that decision can be delayed until v7.0.0. Both may be useful as well:

  • separator to offer arbitrary splitting
  • segmenter: false to avoid creating segmenters unnecessarily if your countFunction does not need one

Keen to lock in the API so we can release this on NHS.UK frontend

Appreciate that'd reduce the divergence between both our Design Systems. However, we can't guarantee our responsiveness when looking at a topic we're not currently focusing on (like what happened for this PR), so please don't stay stuck because of us.

@colinrotherham colinrotherham force-pushed the character-count-segmenter branch from 29c44bf to b18ddd1 Compare May 14, 2026 13:31
Comment on lines +167 to +170
this.countFunctionContext = {
config: this.config,
segmenter: this.segmenter
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having that context in place makes it easier to explain what I was on about with using the segmenter when counting words.

The idea is still to throw on line 148 if the Segmenter API is not available, not to fallback on the other way of counting when the API is not there.

Because we know the component will only keep initialising when a segmenter is needed if the Segmenter API is available, we can reduce the context to the following, keeping the initial public API narrower (less risk of breaking change in the future) and keeping all config related computations internal to the constructor.

Suggested change
this.countFunctionContext = {
config: this.config,
segmenter: this.segmenter
}
this.countFunctionContext = {
segmenter: this.segmenter
}

Then words can check if (this.segmenter) instead of if (this.config.maxwords).

Hope that makes more sense, aim is to control how much of a public API we offer at the start to avoid having to roll back on it down the line 😊

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, glad that made sense

Hmm you might want to hold off hiding the config for a major breaking release though?

Users that provide countFunction will use the config to:

  • Determine whether the (deprecated) maxwords option is used
  • Determine whether they're counting "length", "characters" or "words"
  • Provide their own non-segmenter fallback based on config.countType

Especially when passing JavaScript configuration via initAll() or createAll() because a single application-wide countFunction will at least need to know the config.countType?

Without a config they can't provide their own fallbacks should the non-JS fallback be unsuitable

@colinrotherham colinrotherham Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an example of what a global count function might look like:

import { importAll, CharacterCount } from 'govuk-frontend'

initAll({
  characterCount: {
    /**
     * Custom global count function
     */
    countFunction(text, context) {
      const { config, segmenter } = context

      // Defer to default word count
      if (config.countType === 'words') {
        return CharacterCount.countFunctions.words(text, context)
      }

      // Trim text before counting
      const trimmed = text.trim()

      // Restore support for older browsers
      if (!segmenter) {
        return trimmed.length
      }

      // Return custom count (using trimmed text)
      return Array.from(segmenter.segment(trimmed)).length
    }
  }
})

@colinrotherham colinrotherham force-pushed the character-count-segmenter branch from b454c9c to 16c93b4 Compare June 23, 2026 08:40
@colinrotherham

Copy link
Copy Markdown
Contributor Author

@romaricpascal I've updated this PR to remove the countFunction option

I've left in support for functions as component options in these commits:

With the changes to support a custom count function in a separate branch (see diff)

@romaricpascal romaricpascal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cheers for updating the PR removing the countFunction option and documenting what you did with it.

Code looks almost good to go, @NickColley and I just noticed that the component uses granularity: 'grapheme' when countType is words. It is a bit odd as we'd have expected word.

Is that how it's meant to be or something that slipped through the cracks when moving code around? 😊

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue Should the ganularity be word when countType is words?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I've had to change this so much it's in the removed commits. I'll bring it back:

this.segmenter = new Intl.Segmenter(this.i18n.locale, {
  granularity: this.config.countType === 'words' ? 'word' : 'grapheme'
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget the bit about Test coverage in the PR description 👀

I've held off porting them from NHS.UK frontend until you're happy with the implementation

It's fixed and pushed now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cheers, figured it might have slipped through all the code move. Thanks for adding them back 😊

Re. test coverage, if you have extra tests around the countType option and deprecation of maxwords, we'd definitely be interested, as well as in a base CHANGELOG entry to kickstart the content process (we'll also review the docs of the new options).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heya! Sorry, hadn't realised I could let you know which tests from the list in the description we could use 😓

I think if we grab the tests from these 3 PRs, we'll cover everything that's added here:

CHANGELOG wise, it would be this entry from NHS Frontend covering the use of the segmenter, wouldn't it?

Let me know if that's something you have space to add, otherwise I'm happy to add them to this branch if you're OK with me pushing on your branch 😊

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd forgotten your character count tests still use page.$eval() instead of the helpers

Would you be happy if I applied changes from nhsuk/nhsuk-frontend#1888?

This diff in particular: nhsuk/nhsuk-frontend@ab87dcc#diff-b47de3c71ca81cc72b390d2216518ba095b58342579d6bd0b3490bbd637c0701

Our tests visit the review app but I'm happy to adapt that bit

Unless the (deprecated) `maxwords` option is used
…nter

Unless the (deprecated) `maxwords` option is used
@colinrotherham colinrotherham force-pushed the character-count-segmenter branch from 16c93b4 to 6161fa1 Compare June 25, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Character count's character/word count functions should be customisable Character count component counts code points, not characters

3 participants