-
Notifications
You must be signed in to change notification settings - Fork 128
Adds character counter to TextArea and TextField components #3785
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lindseywild
wants to merge
35
commits into
main
Choose a base branch
from
lw/adds-character-counts
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all 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 62fc2c9
Generating static files
primer-css 491b02b
Updates
lindseywild 6cc1993
Updates character_counter.ts
lindseywild 1600987
Changes
lindseywild 12f53b2
Generating static files
primer-css 28441b0
Restores package-lock.json file
lindseywild a079ec3
Fixes tests
lindseywild d723b47
Adds form_control tests
lindseywild 7e2fd3e
Adds changeset
lindseywild 066066b
Adds integration tests
lindseywild 3530fbb
Generating static files
primer-css 3b60cbd
Updates docs in Lookbook
lindseywild 413f04c
Generating static files
primer-css ab43784
Removes changes to form_control
lindseywild e9595b3
Generating static files
primer-css a65216f
Adds render tests
lindseywild c2186f5
Updates tests
lindseywild 8b09faf
Minor updates
lindseywild a3c4625
Updates tests, moves caption
lindseywild 3b19afb
Generating static files
primer-css f322b8f
Merge branch 'main' into lw/adds-character-counts
lindseywild ee452f7
Copilot suggestions
lindseywild 948c047
More copilot suggestions
lindseywild b95295a
Updates to screen reader element, removes additional validation
lindseywild e456f5f
Generating static files
primer-css 864e1be
Adds / removes icon for error state
lindseywild 20eaed4
Generating static files
primer-css 6410319
Test fixes
lindseywild e5d538e
More test fixes
lindseywild 66efdd6
More tets
lindseywild 342fb84
Tests...again
lindseywild 213eaa6
Revert package-lock.json
lindseywild db4ac1d
Merge branch 'main' into lw/adds-character-counts
lindseywild 8dfc244
Revert package-lock.json again
lindseywild File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,16 @@ | ||
| <% 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" data-target="<%= @input.character_limit_target_prefix %>.characterLimitSrElement" 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 %>" id="<%= @input.character_limit_id %>"> | ||
| <span class="FormControl-caption-icon" hidden><%= render(Primer::Beta::Octicon.new(icon: :"alert-fill", size: :xsmall, aria: { hidden: true })) %></span> | ||
| <span class="FormControl-caption-text"><%= @input.character_limit %> <%= @input.character_limit == 1 ? 'character' : 'characters' %> remaining</span> | ||
| </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 %> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| /** | ||
| * 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 | ||
| private isInitialLoad: boolean = true | ||
|
|
||
| constructor( | ||
| private inputElement: HTMLInputElement | HTMLTextAreaElement, | ||
| private characterLimitElement: HTMLElement, | ||
| private characterLimitSrElement: HTMLElement, | ||
| ) {} | ||
|
|
||
| /** | ||
| * 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() | ||
| this.isInitialLoad = false | ||
| } | ||
|
|
||
| /** | ||
| * Clean up any pending timeouts | ||
| */ | ||
| cleanup(): void { | ||
| if (this.announceTimeout) { | ||
| clearTimeout(this.announceTimeout) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Pluralizes a word based on the count | ||
| */ | ||
| private pluralize(count: number, string: string): string { | ||
| return count === 1 ? string : `${string}s` | ||
| } | ||
|
|
||
| /** | ||
| * 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 = this.pluralize(charactersRemaining, 'character') | ||
| message = `${charactersRemaining} ${characterText} remaining` | ||
| const textSpan = this.characterLimitElement.querySelector('.FormControl-caption-text') | ||
| if (textSpan) { | ||
| textSpan.textContent = message | ||
| } | ||
| this.clearError() | ||
| } else { | ||
| const charactersOver = -charactersRemaining | ||
| const characterText = this.pluralize(charactersOver, 'character') | ||
| message = `${charactersOver} ${characterText} over` | ||
| const textSpan = this.characterLimitElement.querySelector('.FormControl-caption-text') | ||
| if (textSpan) { | ||
| textSpan.textContent = message | ||
| } | ||
| this.setError() | ||
| } | ||
|
|
||
| // We don't want this announced on initial load | ||
| if (!this.isInitialLoad) { | ||
| 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(() => { | ||
| if (this.characterLimitSrElement) { | ||
| this.characterLimitSrElement.textContent = message | ||
| } | ||
| }, this.SCREEN_READER_DELAY) | ||
| } | ||
|
|
||
| /** | ||
| * Set error when character limit is exceeded | ||
| */ | ||
| private setError(): void { | ||
| this.inputElement.setAttribute('invalid', 'true') | ||
| this.inputElement.setAttribute('aria-invalid', 'true') | ||
| this.characterLimitElement.classList.add('fgColor-danger') | ||
|
|
||
| // Show danger icon | ||
| const icon = this.characterLimitElement.querySelector('.FormControl-caption-icon') | ||
| if (icon) { | ||
| icon.removeAttribute('hidden') | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Clear error when back under character limit | ||
| */ | ||
| private clearError(): void { | ||
| this.inputElement.removeAttribute('invalid') | ||
| this.inputElement.removeAttribute('aria-invalid') | ||
| this.characterLimitElement.classList.remove('fgColor-danger') | ||
|
|
||
| // Hide danger icon | ||
| const icon = this.characterLimitElement.querySelector('.FormControl-caption-icon') | ||
| if (icon) { | ||
| icon.setAttribute('hidden', '') | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import {controller, target} from '@github/catalyst' | ||
lindseywild marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import {CharacterCounter} from './character_counter' | ||
lindseywild marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| @controller | ||
| export class PrimerTextAreaElement extends HTMLElement { | ||
| @target inputElement: HTMLTextAreaElement | ||
| @target characterLimitElement: HTMLElement | ||
| @target characterLimitSrElement: HTMLElement | ||
|
|
||
| #characterCounter: CharacterCounter | null = null | ||
|
|
||
| connectedCallback(): void { | ||
| if (this.characterLimitElement) { | ||
| this.#characterCounter = new CharacterCounter( | ||
| this.inputElement, | ||
| this.characterLimitElement, | ||
| this.characterLimitSrElement, | ||
| ) | ||
| 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) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.