A textarea with a character counter widget for Flask, WTForms and Bootstrap

15 February 2020 Updated 18 February 2020 by Peter

Adding a WTForms textarea widget looks easy but differences between Linux and Windows cause unexpected problems.

post main image
https://unsplash.com/@creativegangsters

I hoped to tell you today that you could comment on the blog posts of this website now. That would have meant that I completed the first implementation of the comments system. Unfortunately I stumbled upon some problems, yes of course, I am a programmer, and one of them involved the TextAreaField.

I just wanted a simple extended version of the WTForm TextAreaField, just add a character counter field below the textarea and that's it. I thought this would take few hours but was totally wrong but I somehow solved this and thought to share this with you. Let me know your thoughts ... hmmm ... when the comments get online ... :-)

I am using Bootstrap and jQuery. For jQuery the textarea with remaining characters has been documented many times. Many solutions suggest following the Twitter example. This means that you keep showing the full text even if the number of characters exceeds the allowed number. If the number of characters exceeds the allowed number we show the number of characters in the color red. No problem, I will implement.

Specifing the maximum number of characters at one place

I want the widget to be universal, so no hard-coded values in it. jQuery is used to count the actual number of characters but how does jQuery know what is the maximum allowed? We can define constants and use these everywhere but it is more flexible to implement extra HTML5 data-attributes for the textarea:

  • data-char-count-min
  • data-char-count-max

We add an extra element, showing the remaining number of characters below the textarea. The data-attrbutes can be referenced by jQuery code and used to calculate the number of remaining characters. 

Implementation

Again we first look at the WTForms code for the TextAreaField. See also a previous post. I copied this code and modified it into this:

class TextAreaWithCounterWidget(object):
    """
    Renders a multi-line text area.

    `rows` and `cols` ought to be passed as keyword args when rendering.
    """
    def __init__(self):
        pass

    def __call__(self, field, **kwargs):
        fname = 'TextAreaWithCounterWidget - __call__'

        kwargs.setdefault('id', field.id)
        if 'required' not in kwargs and 'required' in getattr(field, 'flags', []):
            kwargs['required'] = True

        return HTMLString('<textarea %s>%s</textarea>' % (
            html_params(name=field.name, **kwargs),
            escape(text_type(field._value()), quote=False)
        ) + '<span class="" id="' + field.id + '-char-count-num' + '"></span>' )


class TextAreaWithCounterField(StringField):
    """
    This field represents an HTML ``<textarea>`` and can be used to take
    multi-line input.
    """
    widget = TextAreaWithCounterWidget()

Then I use it as follows:

def strip_whitespace(s):
    if isinstance(s, str):
        s = s.strip()
    return s

class ContentItemCommentForm(FlaskForm):

    message = TextAreaWithCounterField(_l('Your message'), 
        render_kw={'data-char-count-min': 0, 'data-char-count-max': 1000, 'rows': 4},
        validators=[ InputRequired(), Length(min=6, max=1000) ],
        filters=[ strip_whitespace ] )

    submit = SubmitField(_l('Add'))

Sometimes things are more easy than expected. We can simply add our new parameters to the form using render_kw. Then in the widget they are passed as attributes to the textarea. If we want we can also access these parameters in the widget using kwargs. We can reference them as:

    data_char_count_max = kwargs['data-char-count-max']

We can also pop them from kwargs, meaning read and remove. Then they will not appear as attributes in the textarea:

    char_count_max = kwargs.pop('char_count_max', None)

But there is no need to use this here. Inside the widget we also can access the Length validator values but again, no need for this here.

I also specified the number of rows in render_kw. This is passed to the textarea field. The generated HTML is appended with the extra element showing the remaining number of characters. The id of this element is constructed from the textarea id:

    field.id + '-char-count-num'

The filter strip_whitespace is called to trim leading and trailing white space.

The jQuery code is not that difficult. I am using Bootstrap, the classes change the color and set padding:

function update_character_count(textarea_id){

	var char_count_num_id = textarea_id + '-char-count-num';

	if( ($("#" + textarea_id).length == 0) || ($("#" + char_count_num_id).length == 0) ){
		// must exist
		return;
	}

	var char_count_min = parseInt( $('#' + textarea_id).data('char-count-min'), 10 );
	var char_count_max = parseInt( $('#' + textarea_id).data('char-count-max'), 10 );

	var remaining = char_count_max - $('#' + textarea_id).val().length;
	$('#' + char_count_num_id).html( '' + remaining );

	if(remaining >= 0){
		$('#' + char_count_num_id).removeClass('text-danger');
		$('#' + char_count_num_id).addClass('pl-2 text-secondary');
	}else{
		$('#' + char_count_num_id).removeClass('text-secondary');
		$('#' + char_count_num_id).addClass('pl-2 text-danger');
	}
}

and:

$(document).ready(function(){
	...
	// comment form: character count
	update_character_count('comment_form-message');
	$('#comment_form-message').on('change input paste', function(event){
		update_character_count('comment_form-message');
	});
}

Testing and mismatch in character count

Now we can start testing the new TextAreaWithCounterField. Everything looked fine until I started entering newlines. The length was reported correct by jQuery which means that they were counted as a single character. But the WTForms validator said that the maximum length was exceeded. Debugging time again, in the widget I printed the characters received by WTForms:

    message = field.data
    if message is None:
        current_app.logger.debug(fname + ': message is None')
    else:
        current_app.logger.debug(fname + ': message = {}, len(message) = {}, list(message) = {}'.format(message, len(message), list(message)))

This gave me the following result:

    len(message) = 4, list(message) = ['a', '\r', '\n', 'b']

jQuery counts \r\n as a single character but WTForms counts it as two characters. Digging into WTForms code, validators.py, we see that it uses the Python len function to determine the length:

    l = field.data and len(field.data) or 0

Understandable but in this case it is wrong! What to do? I did not want to override the default WTForms validation function. Fortunately we have the filters that are called before (!) validation. I was already using strip_whitespace and added a new filter:

def compress_newline(s):
    if isinstance(s, str):
        s = s.replace("\r\n", "\n")
        s = re.sub(r'\n+', '\n', s)
    return s

This filter replaces \r\n with a single \n. In addition, it replaces multiple \n characters by a single \n. The latter is just a very primitive protection against unavoidable wrong (and crazy) submits. The filters line then becomes:

    filters=[ strip_whitespace, compress_newline ] )

Now it worked as intended.

Summary

Using render_kw is an easy way to pass parameters to the widget. I searched the internet for the \r\n problem with the WTForms TextAreaField but could not find any references. Please do not tell me I am the only one. The problem is caused by a mismatch between Windows and Linux systems. Also, Javascript/jQuery coding takes time if you do not do this all the time. 

Links / credits

How to specify rows and columns of a <textarea > tag using wtforms
https://stackoverflow.com/questions/4930747/how-to-specify-rows-and-columns-of-a-textarea-tag-using-wtforms

New line in text area
https://stackoverflow.com/questions/8627902/new-line-in-text-area

Using data attributes
https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes

WTForms - Fields
https://wtforms.readthedocs.io/en/stable/fields.html

WTForms - Widgets
https://wtforms.readthedocs.io/en/stable/widgets.html