Skip to content

Commit

Permalink
Add character count on edit document page
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-ju committed Aug 21, 2018
1 parent d6a2dcc commit d104109
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 0 deletions.
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ $govuk-global-styles: true;
@import "components/markdown-toolbar";
@import "components/summary";
@import "components/metadata";
@import "components/character-count";

@import "utilities/display";
23 changes: 23 additions & 0 deletions app/assets/stylesheets/components/_character-count.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@import "../../../node_modules/govuk-frontend/settings/all";
@import "../../../node_modules/govuk-frontend/helpers/all";
@import "../../../node_modules/govuk-frontend/tools/all";

.govuk-textarea[data-module="character-count"],
.govuk-input[data-module="character-count"] {
display: block;
margin-bottom: govuk-spacing(1);
}

.govuk-textarea--error[data-module="character-count"],
.govuk-input--error[data-module="character-count"] {
padding: govuk-spacing(1) - 2; // stop a "jump" when width of border changes
}

.govuk-character-count__message {
margin-top: govuk-spacing(1);
margin-bottom: 0;
}

.govuk-character-count__message--disabled {
visibility: hidden;
}
187 changes: 187 additions & 0 deletions app/javascript/components/character-count.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
function CharacterCount ($module) {
this.$module = $module
}

CharacterCount.prototype.defaults = {
characterCountAttribute: 'data-maxlength',
wordCountAttribute: 'data-maxwords'
}

// Initialize component
CharacterCount.prototype.init = function (options) {
// Check for module
var $module = this.$module
if (!$module) {
return
}

// Read options set using dataset ('data-' values) and merge them with options
var dataset = this.getDataset($module)
this.options = this.merge(options || {}, dataset)

// Determine the limit attribute (characters or words)
var countAttribute = (this.options.maxwords) ? this.defaults.wordCountAttribute : this.defaults.characterCountAttribute

// Set the element limit
if ($module.getAttribute) {
this.maxLength = $module.getAttribute(countAttribute)
}

// Generate and reference message
var boundCreateCountMessage = this.createCountMessage.bind(this)
this.countMessage = boundCreateCountMessage()

// If there's a maximum length defined and the count message was successfully applied
if (this.maxLength && this.countMessage) {
// Replace maxlength attribute with data-maxlength to avoid hard limit
$module.setAttribute('maxlength', '')
$module.setAttribute('data-maxlength', $module.maxLength)

// Bind event changes to the textarea
var boundChangeEvents = this.bindChangeEvents.bind(this)
boundChangeEvents()

// Update count message
var boundUpdateCountMessage = this.updateCountMessage.bind(this)
boundUpdateCountMessage()
}
}

// Fills in default values
CharacterCount.prototype.merge = function (obj) {
for (var i = 1; i < arguments.length; i++) {
var def = arguments[i]
for (var n in def) {
if (obj[n] === undefined) obj[n] = def[n]
}
}
return obj
}

CharacterCount.prototype.getDataset = function (element) {
var dataset = {}
var attributes = element.attributes
if (attributes) {
for (var i = 0; i < attributes.length; i++) {
var attribute = attributes[i]
var match = attribute.name.match(/^data-(.+)/)
if (match) {
dataset[match[1]] = attribute.value
}
}
}
return dataset
}

// Counts characters or words in text
CharacterCount.prototype.count = function (text, options) {
var length
if (options.maxwords) {
var tokens = text.match(/\S+/g) || [] // Matches consecutive non-whitespace chars
length = tokens.length
} else {
length = text.length
}
return length
}

// Generate count message and bind it to the input
// returns reference to the generated element
CharacterCount.prototype.createCountMessage = function () {
var countElement = this.$module
var elementId = countElement.id
// Check for existing info count message
var countMessage = document.getElementById(elementId + '-info')
// If there is no existing info count message we add one right after the field
if (elementId && !countMessage) {
countElement.insertAdjacentHTML('afterend', '<span id="' + elementId + '-info" class="govuk-hint govuk-character-count__message" aria-live="polite"></span>')
this.describedBy = countElement.getAttribute('aria-describedby')
this.describedByInfo = this.describedBy + ' ' + elementId + '-info'
countElement.setAttribute('aria-describedby', this.describedByInfo)
countMessage = document.getElementById(elementId + '-info')
}
return countMessage
}

// Bind input propertychange to the elements and update based on the change
CharacterCount.prototype.bindChangeEvents = function () {
var $module = this.$module
$module.addEventListener('keyup', this.updateCountMessage.bind(this))

// Bind focus/blur events to start/stop polling
$module.addEventListener('focus', this.handleFocus.bind(this))
$module.addEventListener('blur', this.handleBlur.bind(this))
}

// Speech recognition software such as Dragon NaturallySpeaking will modify the
// fields by directly changing its `value`. These changes don't trigger events
// in JavaScript, so we need to poll to handle when and if they occur.
CharacterCount.prototype.checkIfValueChanged = function () {
if (!this.oldValue) this.oldValue = ''
if (this.$module.value !== this.oldValue) {
this.oldValue = this.$module.value
var boundUpdateCountMessage = this.updateCountMessage.bind(this)
boundUpdateCountMessage()
}
}

// Update message box
CharacterCount.prototype.updateCountMessage = function () {
var countElement = this.$module
var options = this.options
var countMessage = this.countMessage

// Determine the remaining number of characters/words
var currentLength = this.count(countElement.value, options)
var maxLength = this.maxLength
var remainingNumber = maxLength - currentLength

// Set threshold if presented in options
var threshold = 0
if (options.threshold) {
threshold = options.threshold
}
var thresholdValue = maxLength * threshold / 100
if (thresholdValue > currentLength) {
countMessage.classList.add('govuk-character-count__message--disabled')
} else {
countMessage.classList.remove('govuk-character-count__message--disabled')
}

// Update styles
if (remainingNumber < 0) {
countElement.classList.add('govuk-textarea--error')
countMessage.classList.remove('govuk-hint')
countMessage.classList.add('govuk-error-message')
} else {
countElement.classList.remove('govuk-textarea--error')
countMessage.classList.remove('govuk-error-message')
countMessage.classList.add('govuk-hint')
}

// Update message
var charVerb = 'remaining'
var charNoun = 'character'
var displayNumber = remainingNumber
if (options.maxwords) {
charNoun = 'word'
}
charNoun = charNoun + ((remainingNumber === -1 || remainingNumber === 1) ? '' : 's')

charVerb = (remainingNumber < 0) ? 'too many' : 'remaining'
displayNumber = Math.abs(remainingNumber)

countMessage.innerHTML = 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb
}

CharacterCount.prototype.handleFocus = function () {
// Check if value changed on focus
this.valueChecker = setInterval(this.checkIfValueChanged.bind(this), 1000)
}

CharacterCount.prototype.handleBlur = function () {
// Cancel value checking on blur
clearInterval(this.valueChecker)
}

export default CharacterCount
2 changes: 2 additions & 0 deletions app/javascript/packs/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import restrict from '../restrict'
import editDocumentForm from '../documents/edit'
import MarkdownEditor from '../components/markdown-editor'
import ContextualGuidance from '../components/contextual-guidance'
import CharacterCount from '../components/character-count'
import Raven from 'raven-js'

var $sentryDsn = document.querySelector('meta[name=sentry-dsn]')
Expand All @@ -25,6 +26,7 @@ if ($sentryDsn && $sentryCurrentEnv) {

restrict('edit-document-form', editDocumentForm)
restrict('markdown-editor', ($el) => new MarkdownEditor($el).init())
restrict('character-count', ($el) => new CharacterCount($el).init())

// Initialise guidance at document level
var guidance = new ContextualGuidance()
Expand Down
Empty file.
4 changes: 4 additions & 0 deletions app/views/documents/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
name: "document[title]",
value: @document.title,
data: {
"module": "character-count",
"maxlength": "65",
"contextual-guidance": "document-title-guidance"
}
} %>
Expand Down Expand Up @@ -55,6 +57,8 @@
value: @document.summary,
rows: 4,
data: {
"module": "character-count",
"maxlength": "160",
"contextual-guidance": "document-summary-guidance"
}
} %>
Expand Down

0 comments on commit d104109

Please sign in to comment.