Skip to content

Commit 7526370

Browse files
Adds character counter to TextArea and TextField components (#3785)
Co-authored-by: primer-css <primer-css@users.noreply.github.com>
1 parent d52ddf7 commit 7526370

29 files changed

Lines changed: 870 additions & 27 deletions

.changeset/plenty-regions-warn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/view-components': minor
3+
---
4+
5+
Adds character_limit option to TextArea and TextField components

app/components/primer/alpha/text_area.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class TextArea < Primer::Component
1818
# @!method initialize
1919
#
2020
# @macro form_full_width_arguments
21+
# @macro form_input_character_limit_arguments
2122
# @macro form_input_arguments
2223
end
2324
end

app/components/primer/alpha/text_field.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class TextField < Primer::Component
1919
#
2020
# @macro form_size_arguments
2121
# @macro form_full_width_arguments
22+
# @macro form_input_character_limit_arguments
2223
# @macro form_input_arguments
2324
#
2425
# @param placeholder [String] Placeholder text.

app/components/primer/primer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import './beta/relative_time'
2222
import './alpha/tab_container'
2323
import '../../lib/primer/forms/primer_multi_input'
2424
import '../../lib/primer/forms/primer_text_field'
25+
import '../../lib/primer/forms/primer_text_area'
2526
import '../../lib/primer/forms/toggle_switch_input'
2627
import './alpha/action_menu/action_menu_element'
2728
import './alpha/select_panel_element'
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
# :nodoc:
4+
class TextAreaWithCharacterLimitForm < ApplicationForm
5+
form do |my_form|
6+
my_form.text_area(
7+
name: :bio,
8+
label: "Bio",
9+
caption: "Tell us about yourself",
10+
character_limit: 100
11+
)
12+
end
13+
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
# :nodoc:
4+
class TextFieldWithCharacterLimitForm < ApplicationForm
5+
form do |my_form|
6+
my_form.text_field(
7+
name: :username,
8+
label: "Username",
9+
caption: "Choose a unique username",
10+
character_limit: 20
11+
)
12+
end
13+
end
Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
<% if @input.caption? && !@input.caption.blank? %>
2-
<span class="FormControl-caption" id="<%= @input.caption_id %>"><%= @input.caption %></span>
3-
<% elsif caption_template? %>
4-
<% caption_template = render_caption_template %>
5-
<% unless caption_template.blank? %>
6-
<span class="FormControl-caption" id="<%= @input.caption_id %>">
7-
<%= caption_template %>
8-
</span>
9-
<% end %>
1+
<% if @input.character_limit? %>
2+
<span class="sr-only" data-target="<%= @input.character_limit_target_prefix %>.characterLimitSrElement" aria-live="polite" role="status"></span>
3+
<span class="sr-only" id="<%= @input.character_limit_id %>">You can enter up to <%= @input.character_limit %> <%= @input.character_limit == 1 ? 'character' : 'characters' %></span>
4+
<span class="FormControl-caption" data-target="<%= @input.character_limit_target_prefix %>.characterLimitElement" data-max-length="<%= @input.character_limit %>" aria-hidden="true">
5+
<span class="FormControl-caption-icon" hidden><%= render(Primer::Beta::Octicon.new(icon: :"alert-fill", size: :xsmall, aria: { hidden: true })) %></span>
6+
<span class="FormControl-caption-text"><%= @input.character_limit %> <%= @input.character_limit == 1 ? 'character' : 'characters' %> remaining</span>
7+
</span>
8+
<% end %>
9+
<% if @input.caption? || caption_template? %>
10+
<span class="FormControl-caption" id="<%= @input.caption_id %>">
11+
<% if @input.caption? && !@input.caption.blank? %>
12+
<%= @input.caption %>
13+
<% elsif caption_template? %>
14+
<%= render_caption_template %>
15+
<% end %>
16+
</span>
1017
<% end %>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Shared character counting functionality for text inputs with character limits.
3+
* Handles real-time character count updates, validation, and aria-live announcements.
4+
*/
5+
export class CharacterCounter {
6+
private SCREEN_READER_DELAY: number = 500
7+
private announceTimeout: number | null = null
8+
private isInitialLoad: boolean = true
9+
10+
constructor(
11+
private inputElement: HTMLInputElement | HTMLTextAreaElement,
12+
private characterLimitElement: HTMLElement,
13+
private characterLimitSrElement: HTMLElement,
14+
) {}
15+
16+
/**
17+
* Initialize character counting by setting up event listener and initial count
18+
*/
19+
initialize(signal?: AbortSignal): void {
20+
this.inputElement.addEventListener('keyup', () => this.updateCharacterCount(), signal ? {signal} : undefined) // Keyup used over input for better screen reader support
21+
this.inputElement.addEventListener(
22+
'paste',
23+
() => setTimeout(() => this.updateCharacterCount(), 50), // Gives the pasted content time to register
24+
signal ? {signal} : undefined,
25+
)
26+
this.updateCharacterCount()
27+
this.isInitialLoad = false
28+
}
29+
30+
/**
31+
* Clean up any pending timeouts
32+
*/
33+
cleanup(): void {
34+
if (this.announceTimeout) {
35+
clearTimeout(this.announceTimeout)
36+
}
37+
}
38+
39+
/**
40+
* Pluralizes a word based on the count
41+
*/
42+
private pluralize(count: number, string: string): string {
43+
return count === 1 ? string : `${string}s`
44+
}
45+
46+
/**
47+
* Update the character count display and validation state
48+
*/
49+
private updateCharacterCount(): void {
50+
if (!this.characterLimitElement) return
51+
52+
const maxLengthAttr = this.characterLimitElement.getAttribute('data-max-length')
53+
if (!maxLengthAttr) return
54+
55+
const maxLength = parseInt(maxLengthAttr, 10)
56+
const currentLength = this.inputElement.value.length
57+
const charactersRemaining = maxLength - currentLength
58+
let message = ''
59+
60+
if (charactersRemaining >= 0) {
61+
const characterText = this.pluralize(charactersRemaining, 'character')
62+
message = `${charactersRemaining} ${characterText} remaining`
63+
const textSpan = this.characterLimitElement.querySelector('.FormControl-caption-text')
64+
if (textSpan) {
65+
textSpan.textContent = message
66+
}
67+
this.clearError()
68+
} else {
69+
const charactersOver = -charactersRemaining
70+
const characterText = this.pluralize(charactersOver, 'character')
71+
message = `${charactersOver} ${characterText} over`
72+
const textSpan = this.characterLimitElement.querySelector('.FormControl-caption-text')
73+
if (textSpan) {
74+
textSpan.textContent = message
75+
}
76+
this.setError()
77+
}
78+
79+
// We don't want this announced on initial load
80+
if (!this.isInitialLoad) {
81+
this.announceToScreenReader(message)
82+
}
83+
}
84+
85+
/**
86+
* Announce character count to screen readers with debouncing
87+
*/
88+
private announceToScreenReader(message: string): void {
89+
if (this.announceTimeout) {
90+
clearTimeout(this.announceTimeout)
91+
}
92+
93+
this.announceTimeout = window.setTimeout(() => {
94+
if (this.characterLimitSrElement) {
95+
this.characterLimitSrElement.textContent = message
96+
}
97+
}, this.SCREEN_READER_DELAY)
98+
}
99+
100+
/**
101+
* Set error when character limit is exceeded
102+
*/
103+
private setError(): void {
104+
this.inputElement.setAttribute('invalid', 'true')
105+
this.inputElement.setAttribute('aria-invalid', 'true')
106+
this.characterLimitElement.classList.add('fgColor-danger')
107+
108+
// Show danger icon
109+
const icon = this.characterLimitElement.querySelector('.FormControl-caption-icon')
110+
if (icon) {
111+
icon.removeAttribute('hidden')
112+
}
113+
}
114+
115+
/**
116+
* Clear error when back under character limit
117+
*/
118+
private clearError(): void {
119+
this.inputElement.removeAttribute('invalid')
120+
this.inputElement.removeAttribute('aria-invalid')
121+
this.characterLimitElement.classList.remove('fgColor-danger')
122+
123+
// Hide danger icon
124+
const icon = this.characterLimitElement.querySelector('.FormControl-caption-icon')
125+
if (icon) {
126+
icon.setAttribute('hidden', '')
127+
}
128+
}
129+
}

app/lib/primer/forms/dsl/input.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ class Input
3333
# @!macro [new] form_full_width_arguments
3434
# @param full_width [Boolean] When set to `true`, the field will take up all the horizontal space allowed by its container. Defaults to `true`.
3535

36+
# @!macro [new] form_input_character_limit_arguments
37+
# @param character_limit [Number] Optional character limit for the input. If provided, a character counter will be displayed below the input.
38+
3639
# @!macro [new] form_system_arguments
3740
# @param system_arguments [Hash] A hash of attributes passed to the underlying Rails builder methods. These options may mean something special depending on the type of input, otherwise they are emitted as HTML attributes. See the [Rails documentation](https://guides.rubyonrails.org/form_helpers.html) for more information. In addition, the usual Primer utility arguments are accepted in system arguments. For example, passing `mt: 2` will add the `mt-2` class to the input. See the Primer system arguments docs for details.
3841

@@ -112,6 +115,7 @@ def initialize(builder:, form:, **system_arguments)
112115

113116
@ids = {}.tap do |id_map|
114117
id_map[:validation] = "validation-#{@base_id}" if supports_validation?
118+
id_map[:character_limit_caption] = "character_limit-#{@base_id}" if character_limit?
115119
id_map[:caption] = "caption-#{@base_id}" if caption? || caption_template?
116120
end
117121

@@ -196,6 +200,25 @@ def render_caption_template
196200
form.render_caption_template(caption_template_name)
197201
end
198202

203+
def character_limit?
204+
false
205+
end
206+
207+
def character_limit_id
208+
ids[:character_limit_caption]
209+
end
210+
211+
def character_limit_target_prefix
212+
case type
213+
when :text_field
214+
"primer-text-field"
215+
when :text_area
216+
"primer-text-area"
217+
else
218+
""
219+
end
220+
end
221+
199222
def valid?
200223
supports_validation? && validation_messages.empty? && !@invalid
201224
end

app/lib/primer/forms/dsl/text_area_input.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,20 @@ module Forms
55
module Dsl
66
# :nodoc:
77
class TextAreaInput < Input
8-
attr_reader :name, :label
8+
attr_reader :name, :label, :character_limit
99

1010
def initialize(name:, label:, **system_arguments)
1111
@name = name
1212
@label = label
13+
@character_limit = system_arguments.delete(:character_limit)
14+
15+
if @character_limit.present? && @character_limit.to_i <= 0
16+
raise ArgumentError, "character_limit must be a positive integer, got #{@character_limit}"
17+
end
1318

1419
super(**system_arguments)
20+
21+
add_input_data(:target, "primer-text-area.inputElement")
1522
end
1623

1724
def to_component
@@ -22,6 +29,10 @@ def type
2229
:text_area
2330
end
2431

32+
def character_limit?
33+
@character_limit.present?
34+
end
35+
2536
# :nocov:
2637
def focusable?
2738
true

0 commit comments

Comments
 (0)