Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
10 changes: 9 additions & 1 deletion app/components/primer/alpha/form_control.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,16 @@ class FormControl < Primer::Component
# @param visually_hide_label [Boolean] When set to `true`, hides the label. Although the label will be hidden visually, it will still be visible to screen readers.
# @param full_width [Boolean] When set to `true`, the form control will take up all the horizontal space allowed by its container.
# @param label_arguments [Hash] HTML attributes to attach to the `<label>` element that labels the input.
# @param character_limit [Number] Optional character limit for the input. If provided, a character counter will be displayed below the input.
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
def initialize(label:, caption: nil, validation_message: nil, required: false, visually_hide_label: false, full_width: false, label_arguments: {}, **system_arguments)
def initialize(label:, caption: nil, validation_message: nil, required: false, visually_hide_label: false, full_width: false, label_arguments: {}, character_limit: nil, **system_arguments)
@label = label
@init_caption = caption
@validation_message = validation_message
@required = required
@visually_hide_label = visually_hide_label
@full_width = full_width
@character_limit = character_limit
@label_arguments = label_arguments
@system_arguments = system_arguments

Expand Down Expand Up @@ -108,6 +110,12 @@ def full_width?
@full_width
end

# Returns the character limit for the input, if set.
# @returns [Number, nil]
def character_limit?
@character_limit
end

private

def before_render
Expand Down
6 changes: 6 additions & 0 deletions app/components/primer/alpha/text_field.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
color: var(--fgColor-muted);
}

/* caption with character limit */
.FormControl-caption--characterLimit {
margin-bottom: var(--base-size-4);
}


/* inline validation message */
.FormControl-inlineValidation {
display: flex;
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
25 changes: 16 additions & 9 deletions app/lib/primer/forms/caption.html.erb
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
<% 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"></span>
<% end %>
<% if @input.caption? || caption_template? || @input.character_limit? %>
<span class="FormControl-caption" id="<%= @input.caption_id %>">
<% if @input.character_limit? %>
<div class="FormControl-caption--characterLimit" data-target="<%= @input.character_limit_target_prefix %>.characterLimitElement" data-max-length="<%= @input.character_limit %>" data-sr-target="<%= @input.character_limit_sr_id %>" aria-hidden="true">
<%= @input.character_limit %> characters remaining.
</div>
<% end %>
<% if @input.caption? && [email protected]? %>
<%= @input.caption %>
<% elsif caption_template? %>
<%= render_caption_template %>
<% end %>
</span>
<% end %>
133 changes: 133 additions & 0 deletions app/lib/primer/forms/character_counter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* 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 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 maxLength = parseInt(this.characterLimitElement.getAttribute('data-max-length') || '0', 10)
if (maxLength === 0) return

const currentLength = this.inputElement.value.length
const remaining = maxLength - currentLength
let message = ''

if (remaining >= 0) {
// Still under or at the limit
const word = remaining === 1 ? 'character' : 'characters'
message = `${remaining} ${word} remaining.`
this.characterLimitElement.textContent = message
this.clearError()
} else {
// Over the limit
const over = Math.abs(remaining)
const word = over === 1 ? 'character' : 'characters'
message = `${over} ${word} over.`
this.characterLimitElement.textContent = message
this.setError()
}

// Debounce the aria-live announcement
this.announceToScreenReader(message)
}

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

// Set a new timeout to announce after 150ms
this.announceTimeout = window.setTimeout(() => {
const srTargetId = this.characterLimitElement?.getAttribute('data-sr-target')
if (srTargetId) {
const srElement = document.getElementById(srTargetId)
if (srElement) {
srElement.textContent = message
}
}
}, 150)
}

/**
* Show validation error when character limit is exceeded
*/
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
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
}
}
15 changes: 15 additions & 0 deletions app/lib/primer/forms/dsl/input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,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
17 changes: 16 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,16 @@ 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)

super(**system_arguments)

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

def to_component
Expand All @@ -22,6 +25,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
15 changes: 14 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,7 @@ 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 @leading_visual
@leading_visual[:classes] = class_names(
Expand Down Expand Up @@ -67,6 +68,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