Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9ce7d13
Initial work to add character counts to TextField and TextArea compon…
lindseywild Dec 3, 2025
62fc2c9
Generating static files
primer-css Dec 3, 2025
491b02b
Updates
lindseywild Dec 3, 2025
6cc1993
Updates character_counter.ts
lindseywild Dec 3, 2025
1600987
Changes
lindseywild Dec 3, 2025
12f53b2
Generating static files
primer-css Dec 3, 2025
28441b0
Restores package-lock.json file
lindseywild Dec 3, 2025
a079ec3
Fixes tests
lindseywild Dec 3, 2025
d723b47
Adds form_control tests
lindseywild Dec 3, 2025
7e2fd3e
Adds changeset
lindseywild Dec 3, 2025
066066b
Adds integration tests
lindseywild Dec 3, 2025
3530fbb
Generating static files
primer-css Dec 3, 2025
3b60cbd
Updates docs in Lookbook
lindseywild Dec 4, 2025
413f04c
Generating static files
primer-css Dec 4, 2025
ab43784
Removes changes to form_control
lindseywild Dec 4, 2025
e9595b3
Generating static files
primer-css Dec 4, 2025
a65216f
Adds render tests
lindseywild Dec 4, 2025
c2186f5
Updates tests
lindseywild Dec 4, 2025
8b09faf
Minor updates
lindseywild Dec 5, 2025
a3c4625
Updates tests, moves caption
lindseywild Dec 5, 2025
3b19afb
Generating static files
primer-css Dec 5, 2025
f322b8f
Merge branch 'main' into lw/adds-character-counts
lindseywild Dec 5, 2025
ee452f7
Copilot suggestions
lindseywild Dec 8, 2025
948c047
More copilot suggestions
lindseywild Dec 8, 2025
b95295a
Updates to screen reader element, removes additional validation
lindseywild Dec 10, 2025
e456f5f
Generating static files
primer-css Dec 10, 2025
864e1be
Adds / removes icon for error state
lindseywild Dec 10, 2025
20eaed4
Generating static files
primer-css Dec 10, 2025
6410319
Test fixes
lindseywild Dec 10, 2025
e5d538e
More test fixes
lindseywild Dec 10, 2025
66efdd6
More tets
lindseywild Dec 10, 2025
342fb84
Tests...again
lindseywild Dec 10, 2025
213eaa6
Revert package-lock.json
lindseywild Dec 10, 2025
db4ac1d
Merge branch 'main' into lw/adds-character-counts
lindseywild Dec 10, 2025
8dfc244
Revert package-lock.json again
lindseywild Dec 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-regions-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/view-components': minor
---

Adds character_limit option to TextArea and TextField components
1 change: 1 addition & 0 deletions app/components/primer/alpha/text_area.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class TextArea < Primer::Component
# @!method initialize
#
# @macro form_full_width_arguments
# @macro form_input_character_limit_arguments
# @macro form_input_arguments
end
end
Expand Down
1 change: 1 addition & 0 deletions app/components/primer/alpha/text_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class TextField < Primer::Component
#
# @macro form_size_arguments
# @macro form_full_width_arguments
# @macro form_input_character_limit_arguments
# @macro form_input_arguments
#
# @param placeholder [String] Placeholder text.
Expand Down
1 change: 1 addition & 0 deletions app/components/primer/primer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import './beta/relative_time'
import './alpha/tab_container'
import '../../lib/primer/forms/primer_multi_input'
import '../../lib/primer/forms/primer_text_field'
import '../../lib/primer/forms/primer_text_area'
import '../../lib/primer/forms/toggle_switch_input'
import './alpha/action_menu/action_menu_element'
import './alpha/select_panel_element'
Expand Down
13 changes: 13 additions & 0 deletions app/forms/text_area_with_character_limit_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

# :nodoc:
class TextAreaWithCharacterLimitForm < ApplicationForm
form do |my_form|
my_form.text_area(
name: :bio,
label: "Bio",
caption: "Tell us about yourself",
character_limit: 100
)
end
end
13 changes: 13 additions & 0 deletions app/forms/text_field_with_character_limit_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

# :nodoc:
class TextFieldWithCharacterLimitForm < ApplicationForm
form do |my_form|
my_form.text_field(
name: :username,
label: "Username",
caption: "Choose a unique username",
character_limit: 20
)
end
end
23 changes: 14 additions & 9 deletions app/lib/primer/forms/caption.html.erb
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
<% if @input.caption? && [email protected]? %>
<span class="FormControl-caption" id="<%= @input.caption_id %>"><%= @input.caption %></span>
<% elsif caption_template? %>
<% caption_template = render_caption_template %>
<% unless caption_template.blank? %>
<span class="FormControl-caption" id="<%= @input.caption_id %>">
<%= caption_template %>
</span>
<% end %>
<% if @input.character_limit? %>
<span class="sr-only" id="<%= @input.character_limit_sr_id %>" aria-live="polite" aria-atomic="true"></span>
<span class="FormControl-caption" data-target="<%= @input.character_limit_target_prefix %>.characterLimitElement" data-max-length="<%= @input.character_limit %>" data-sr-target="<%= @input.character_limit_sr_id %>">
<%= @input.character_limit %> <%= @input.character_limit == 1 ? 'character' : 'characters' %> remaining.
</span>
<% end %>
<% if @input.caption? || caption_template? %>
<span class="FormControl-caption" id="<%= @input.caption_id %>">
<% if @input.caption? && [email protected]? %>
<%= @input.caption %>
<% elsif caption_template? %>
<%= render_caption_template %>
<% end %>
</span>
<% end %>
131 changes: 131 additions & 0 deletions app/lib/primer/forms/character_counter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Shared character counting functionality for text inputs with character limits.
* Handles real-time character count updates, validation, and aria-live announcements.
*/
export class CharacterCounter {
private SCREEN_READER_DELAY: number = 500
private announceTimeout: number | null = null

constructor(
private inputElement: HTMLInputElement | HTMLTextAreaElement,
private characterLimitElement: HTMLElement,
private validationElement: HTMLElement | undefined,
private validationMessageElement: HTMLElement | undefined,
) {}

/**
* Initialize character counting by setting up event listener and initial count
*/
initialize(signal?: AbortSignal): void {
this.inputElement.addEventListener('input', () => this.updateCharacterCount(), signal ? {signal} : undefined)
this.updateCharacterCount()
}

/**
* Clean up any pending timeouts
*/
cleanup(): void {
if (this.announceTimeout) {
clearTimeout(this.announceTimeout)
}
}

/**
* Update the character count display and validation state
*/
private updateCharacterCount(): void {
if (!this.characterLimitElement) return

const maxLengthAttr = this.characterLimitElement.getAttribute('data-max-length')
if (!maxLengthAttr) return

const maxLength = parseInt(maxLengthAttr, 10)
const currentLength = this.inputElement.value.length
const charactersRemaining = maxLength - currentLength
let message = ''

if (charactersRemaining >= 0) {
const characterText = charactersRemaining === 1 ? 'character' : 'characters'
message = `${charactersRemaining} ${characterText} remaining.`
this.characterLimitElement.textContent = message
this.clearError()
} else {
const charactersOver = -charactersRemaining
const characterText = charactersOver === 1 ? 'character' : 'characters'
message = `${charactersOver} ${characterText} over.`
this.characterLimitElement.textContent = message
this.setError()
}

this.announceToScreenReader(message)
}

/**
* Announce character count to screen readers with debouncing
*/
private announceToScreenReader(message: string): void {
if (this.announceTimeout) {
clearTimeout(this.announceTimeout)
}

this.announceTimeout = window.setTimeout(() => {
const srTargetId = this.characterLimitElement?.getAttribute('data-sr-target')
if (!srTargetId) return

const srElement = document.getElementById(srTargetId)
if (srElement) {
srElement.textContent = message
}
}, this.SCREEN_READER_DELAY)
}

/**
* Show validation error when character limit is exceeded.
* Required since default validation on inputs only appears after an attempt to submit the form.
*/
private setError(): void {
if (!this.validationElement || !this.validationMessageElement) return

this.inputElement.setAttribute('invalid', 'true')
this.inputElement.setAttribute('aria-invalid', 'true')

// Add validation message ID to aria-describedby to ensure it is read by screen readers
const validationId = this.validationElement.id
if (validationId) {
const existingDescribedBy = this.inputElement.getAttribute('aria-describedby') || ''
const describedByIds = existingDescribedBy.split(' ').filter(id => id.length > 0)
if (!describedByIds.includes(validationId)) {
describedByIds.push(validationId)
this.inputElement.setAttribute('aria-describedby', describedByIds.join(' '))
}
}

this.validationMessageElement.textContent = "You've exceeded the character limit"
this.validationElement.hidden = false
}

/**
* Clear validation error when back under character limit
*/
private clearError(): void {
if (!this.validationElement || !this.validationMessageElement) return

this.inputElement.removeAttribute('invalid')
this.inputElement.removeAttribute('aria-invalid')

// Remove validation message ID from aria-describedby
const validationId = this.validationElement.id
if (validationId) {
const existingDescribedBy = this.inputElement.getAttribute('aria-describedby') || ''
const describedByIds = existingDescribedBy.split(' ').filter(id => id.length > 0 && id !== validationId)
if (describedByIds.length > 0) {
this.inputElement.setAttribute('aria-describedby', describedByIds.join(' '))
} else {
this.inputElement.removeAttribute('aria-describedby')
}
}

this.validationMessageElement.textContent = ''
this.validationElement.hidden = true
}
}
18 changes: 18 additions & 0 deletions app/lib/primer/forms/dsl/input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class Input
# @!macro [new] form_full_width_arguments
# @param full_width [Boolean] When set to `true`, the field will take up all the horizontal space allowed by its container. Defaults to `true`.

# @!macro [new] form_input_character_limit_arguments
# @param character_limit [Number] Optional character limit for the input. If provided, a character counter will be displayed below the input.

# @!macro [new] form_system_arguments
# @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.

Expand Down Expand Up @@ -196,6 +199,21 @@ def render_caption_template
form.render_caption_template(caption_template_name)
end

def character_limit?
false
end

def character_limit_target_prefix
case type
when :text_field
"primer-text-field"
when :text_area
"primer-text-area"
else
""
end
end

def valid?
supports_validation? && validation_messages.empty? && !@invalid
end
Expand Down
21 changes: 20 additions & 1 deletion app/lib/primer/forms/dsl/text_area_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@ module Forms
module Dsl
# :nodoc:
class TextAreaInput < Input
attr_reader :name, :label
attr_reader :name, :label, :character_limit

def initialize(name:, label:, **system_arguments)
@name = name
@label = label
@character_limit = system_arguments.delete(:character_limit)

if @character_limit.present? && @character_limit.to_i <= 0
raise ArgumentError, "character_limit must be a positive integer, got #{@character_limit}"
end

super(**system_arguments)

add_input_data(:target, "primer-text-area.inputElement")
end

def to_component
Expand All @@ -22,6 +29,18 @@ def type
:text_area
end

def character_limit?
@character_limit.present?
end

def character_limit_sr_id
@character_limit_sr_id ||= "#{name}-character-count-sr-#{SecureRandom.hex(4)}"
end

def character_limit_validation_id
@character_limit_validation_id ||= "#{name}-character-limit-validation-#{SecureRandom.hex(4)}"
end

# :nocov:
def focusable?
true
Expand Down
19 changes: 18 additions & 1 deletion app/lib/primer/forms/dsl/text_field_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class TextFieldInput < Input
attr_reader(
*%i[
name label show_clear_button leading_visual leading_spinner trailing_visual clear_button_id
visually_hide_label inset monospace field_wrap_classes auto_check_src
visually_hide_label inset monospace field_wrap_classes auto_check_src character_limit
]
)

Expand All @@ -24,6 +24,11 @@ def initialize(name:, label:, **system_arguments)
@inset = system_arguments.delete(:inset)
@monospace = system_arguments.delete(:monospace)
@auto_check_src = system_arguments.delete(:auto_check_src)
@character_limit = system_arguments.delete(:character_limit)

if @character_limit.present? && @character_limit.to_i <= 0
raise ArgumentError, "character_limit must be a positive integer, got #{@character_limit}"
end

if @leading_visual
@leading_visual[:classes] = class_names(
Expand Down Expand Up @@ -67,6 +72,18 @@ def focusable?
true
end

def character_limit?
@character_limit.present?
end

def character_limit_sr_id
@character_limit_sr_id ||= "#{name}-character-count-sr-#{SecureRandom.hex(4)}"
end

def character_limit_validation_id
@character_limit_validation_id ||= "#{name}-character-limit-validation-#{SecureRandom.hex(4)}"
end

def validation_arguments
if auto_check_src.present?
super.merge(
Expand Down
39 changes: 39 additions & 0 deletions app/lib/primer/forms/primer_text_area.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {controller, target} from '@github/catalyst'
import {CharacterCounter} from './character_counter'

@controller
export class PrimerTextAreaElement extends HTMLElement {
@target inputElement: HTMLTextAreaElement
@target characterLimitElement: HTMLElement
@target validationElement: HTMLElement
@target validationMessageElement: HTMLElement

#characterCounter: CharacterCounter | null = null

connectedCallback(): void {
if (this.characterLimitElement) {
this.#characterCounter = new CharacterCounter(
this.inputElement,
this.characterLimitElement,
this.validationElement,
this.validationMessageElement,
)
this.#characterCounter.initialize()
}
}

disconnectedCallback(): void {
this.#characterCounter?.cleanup()
}
}

declare global {
interface Window {
PrimerTextAreaElement: typeof PrimerTextAreaElement
}
}

if (!window.customElements.get('primer-text-area')) {
Object.assign(window, {PrimerTextAreaElement})
window.customElements.define('primer-text-area', PrimerTextAreaElement)
}
Loading
Loading