diff --git a/.changeset/plenty-regions-warn.md b/.changeset/plenty-regions-warn.md new file mode 100644 index 0000000000..19f5164b6c --- /dev/null +++ b/.changeset/plenty-regions-warn.md @@ -0,0 +1,5 @@ +--- +'@primer/view-components': minor +--- + +Adds character_limit option to TextArea and TextField components diff --git a/app/components/primer/alpha/text_area.rb b/app/components/primer/alpha/text_area.rb index 93c216d59e..89ac365030 100644 --- a/app/components/primer/alpha/text_area.rb +++ b/app/components/primer/alpha/text_area.rb @@ -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 diff --git a/app/components/primer/alpha/text_field.rb b/app/components/primer/alpha/text_field.rb index 3fc5590fc2..b33b9d2100 100644 --- a/app/components/primer/alpha/text_field.rb +++ b/app/components/primer/alpha/text_field.rb @@ -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. diff --git a/app/components/primer/primer.ts b/app/components/primer/primer.ts index e93e234afe..2ff0373455 100644 --- a/app/components/primer/primer.ts +++ b/app/components/primer/primer.ts @@ -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' diff --git a/app/forms/text_area_with_character_limit_form.rb b/app/forms/text_area_with_character_limit_form.rb new file mode 100644 index 0000000000..32a46b9a0a --- /dev/null +++ b/app/forms/text_area_with_character_limit_form.rb @@ -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 diff --git a/app/forms/text_field_with_character_limit_form.rb b/app/forms/text_field_with_character_limit_form.rb new file mode 100644 index 0000000000..28c41b47fe --- /dev/null +++ b/app/forms/text_field_with_character_limit_form.rb @@ -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 diff --git a/app/lib/primer/forms/caption.html.erb b/app/lib/primer/forms/caption.html.erb index 73e788d668..26efbeb5d5 100644 --- a/app/lib/primer/forms/caption.html.erb +++ b/app/lib/primer/forms/caption.html.erb @@ -1,10 +1,16 @@ -<% if @input.caption? && !@input.caption.blank? %> - <%= @input.caption %> -<% elsif caption_template? %> - <% caption_template = render_caption_template %> - <% unless caption_template.blank? %> - - <%= caption_template %> - - <% end %> +<% if @input.character_limit? %> + + + + <%= @input.character_limit %> <%= @input.character_limit == 1 ? 'character' : 'characters' %> remaining + +<% end %> +<% if @input.caption? || caption_template? %> + + <% if @input.caption? && !@input.caption.blank? %> + <%= @input.caption %> + <% elsif caption_template? %> + <%= render_caption_template %> + <% end %> + <% end %> diff --git a/app/lib/primer/forms/character_counter.ts b/app/lib/primer/forms/character_counter.ts new file mode 100644 index 0000000000..b64cc0c1cb --- /dev/null +++ b/app/lib/primer/forms/character_counter.ts @@ -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', '') + } + } +} diff --git a/app/lib/primer/forms/dsl/input.rb b/app/lib/primer/forms/dsl/input.rb index 14203a8364..053572319c 100644 --- a/app/lib/primer/forms/dsl/input.rb +++ b/app/lib/primer/forms/dsl/input.rb @@ -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. @@ -112,6 +115,7 @@ def initialize(builder:, form:, **system_arguments) @ids = {}.tap do |id_map| id_map[:validation] = "validation-#{@base_id}" if supports_validation? + id_map[:character_limit_caption] = "character_limit-#{@base_id}" if character_limit? id_map[:caption] = "caption-#{@base_id}" if caption? || caption_template? end @@ -196,6 +200,25 @@ def render_caption_template form.render_caption_template(caption_template_name) end + def character_limit? + false + end + + def character_limit_id + ids[:character_limit_caption] + 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 diff --git a/app/lib/primer/forms/dsl/text_area_input.rb b/app/lib/primer/forms/dsl/text_area_input.rb index d0495e8bd7..f55cce68b0 100644 --- a/app/lib/primer/forms/dsl/text_area_input.rb +++ b/app/lib/primer/forms/dsl/text_area_input.rb @@ -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 @@ -22,6 +29,10 @@ def type :text_area end + def character_limit? + @character_limit.present? + end + # :nocov: def focusable? true diff --git a/app/lib/primer/forms/dsl/text_field_input.rb b/app/lib/primer/forms/dsl/text_field_input.rb index 9558f63006..f9d555c2af 100644 --- a/app/lib/primer/forms/dsl/text_field_input.rb +++ b/app/lib/primer/forms/dsl/text_field_input.rb @@ -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 ] ) @@ -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( @@ -67,6 +72,10 @@ def focusable? true end + def character_limit? + @character_limit.present? + end + def validation_arguments if auto_check_src.present? super.merge( diff --git a/app/lib/primer/forms/primer_text_area.ts b/app/lib/primer/forms/primer_text_area.ts new file mode 100644 index 0000000000..d195bbafdc --- /dev/null +++ b/app/lib/primer/forms/primer_text_area.ts @@ -0,0 +1,37 @@ +import {controller, target} from '@github/catalyst' +import {CharacterCounter} from './character_counter' + +@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) +} diff --git a/app/lib/primer/forms/primer_text_field.ts b/app/lib/primer/forms/primer_text_field.ts index bce11220e6..4f3a59d865 100644 --- a/app/lib/primer/forms/primer_text_field.ts +++ b/app/lib/primer/forms/primer_text_field.ts @@ -1,8 +1,7 @@ -/* eslint-disable custom-elements/expose-class-on-global */ - import '@github/auto-check-element' import type {AutoCheckErrorEvent, AutoCheckSuccessEvent} from '@github/auto-check-element' import {controller, target} from '@github/catalyst' +import {CharacterCounter} from './character_counter' declare global { interface HTMLElementEventMap { @@ -20,8 +19,11 @@ export class PrimerTextFieldElement extends HTMLElement { @target validationErrorIcon: HTMLElement @target leadingVisual: HTMLElement @target leadingSpinner: HTMLElement + @target characterLimitElement: HTMLElement + @target characterLimitSrElement: HTMLElement #abortController: AbortController | null + #characterCounter: CharacterCounter | null = null connectedCallback(): void { this.#abortController?.abort() @@ -48,16 +50,27 @@ export class PrimerTextFieldElement extends HTMLElement { }, {signal}, ) + + // Set up character limit tracking if present + if (this.characterLimitElement) { + this.#characterCounter = new CharacterCounter( + this.inputElement, + this.characterLimitElement, + this.characterLimitSrElement, + ) + this.#characterCounter.initialize(signal) + } } disconnectedCallback() { this.#abortController?.abort() + this.#characterCounter?.cleanup() } clearContents() { this.inputElement.value = '' this.inputElement.focus() - this.inputElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: false })) + this.inputElement.dispatchEvent(new Event('input', {bubbles: true, cancelable: false})) } clearError(): void { diff --git a/app/lib/primer/forms/text_area.html.erb b/app/lib/primer/forms/text_area.html.erb index 2e7bfe1fa3..ad0132865a 100644 --- a/app/lib/primer/forms/text_area.html.erb +++ b/app/lib/primer/forms/text_area.html.erb @@ -1,5 +1,7 @@ -<%= render(FormControl.new(input: @input)) do %> - <%= content_tag(:div, **@field_wrap_arguments) do %> - <%= builder.text_area(@input.name, **@input.input_arguments) %> + + <%= render(FormControl.new(input: @input)) do %> + <%= content_tag(:div, **@field_wrap_arguments) do %> + <%= builder.text_area(@input.name, **@input.input_arguments) %> + <% end %> <% end %> -<% end %> + diff --git a/app/lib/primer/forms/text_field.rb b/app/lib/primer/forms/text_field.rb index 0e715148d0..5972810279 100644 --- a/app/lib/primer/forms/text_field.rb +++ b/app/lib/primer/forms/text_field.rb @@ -77,6 +77,14 @@ def trailing_visual_component Primer::Beta::Truncate.new(**truncate_arguments).with_content(text) end end + + def character_limit_validation_arguments + { + class: "FormControl-inlineValidation", + id: @input.character_limit_validation_id, + hidden: true + } + end end end end diff --git a/previews/primer/alpha/text_area_preview.rb b/previews/primer/alpha/text_area_preview.rb index dd60440115..bae8c65d9a 100644 --- a/previews/primer/alpha/text_area_preview.rb +++ b/previews/primer/alpha/text_area_preview.rb @@ -16,6 +16,7 @@ class TextAreaPreview < ViewComponent::Preview # @param disabled toggle # @param invalid toggle # @param validation_message text + # @param character_limit number def playground( name: "my-text-area", id: "my-text-area", @@ -26,7 +27,8 @@ def playground( full_width: true, disabled: false, invalid: false, - validation_message: nil + validation_message: nil, + character_limit: nil ) system_arguments = { name: name, @@ -38,7 +40,8 @@ def playground( full_width: full_width, disabled: disabled, invalid: invalid, - validation_message: validation_message + validation_message: validation_message, + character_limit: character_limit } render(Primer::Alpha::TextArea.new(**system_arguments)) @@ -93,6 +96,24 @@ def invalid def with_validation_message render(Primer::Alpha::TextArea.new(validation_message: "An error occurred!", name: "my-text-area", label: "Tell me about yourself")) end + + # @label With character limit + # @snapshot interactive + def with_character_limit + render(Primer::Alpha::TextArea.new(character_limit: 10, name: "my-text-area", label: "Tell me about yourself")) + end + + # @label With character limit, over limit + # @snapshot interactive + def with_character_limit_over_limit + render(Primer::Alpha::TextArea.new(character_limit: 10, name: "my-text-area", label: "Tell me about yourself", value: "This text is definitely over the limit.")) + end + + # @label With character limit and caption + # @snapshot + def with_character_limit_and_caption + render(Primer::Alpha::TextArea.new(character_limit: 100, caption: "With a caption.", name: "my-text-area", label: "Tell me about yourself")) + end # # @!endgroup end diff --git a/previews/primer/alpha/text_field_preview.rb b/previews/primer/alpha/text_field_preview.rb index 85beea5ef2..fa7f371063 100644 --- a/previews/primer/alpha/text_field_preview.rb +++ b/previews/primer/alpha/text_field_preview.rb @@ -24,6 +24,7 @@ class TextFieldPreview < ViewComponent::Preview # @param monospace toggle # @param leading_visual_icon octicon # @param leading_spinner toggle + # @param character_limit number def playground( name: "my-text-field", id: "my-text-field", @@ -42,7 +43,8 @@ def playground( inset: false, monospace: false, leading_visual_icon: nil, - leading_spinner: false + leading_spinner: false, + character_limit: nil ) system_arguments = { name: name, @@ -61,7 +63,8 @@ def playground( placeholder: placeholder, inset: inset, monospace: monospace, - leading_spinner: leading_spinner + leading_spinner: leading_spinner, + character_limit: character_limit } if leading_visual_icon @@ -197,7 +200,7 @@ def with_trailing_counter end # @label With trailing label - # @snapshot + # @snapshot def with_trailing_label render(Primer::Alpha::TextField.new(trailing_visual: { label: { text: "Hello" } }, name: "my-text-field-15", label: "My text field")) end @@ -213,6 +216,24 @@ def with_leading_visual def with_validation_message render(Primer::Alpha::TextField.new(validation_message: "An error occurred!", name: "my-text-field-17", label: "My text field")) end + + # @label With character limit + # @snapshot interactive + def with_character_limit + render(Primer::Alpha::TextField.new(character_limit: 10, name: "my-text-field-18", label: "Username")) + end + + # @label With character limit, over limit + # @snapshot interactive + def with_character_limit_over_limit + render(Primer::Alpha::TextField.new(character_limit: 10, name: "my-text-field-19", label: "Tell me about yourself", value: "This text is definitely over the limit.")) + end + + # @label With character limit and caption + # @snapshot + def with_character_limit_and_caption + render(Primer::Alpha::TextField.new(character_limit: 20, caption: "Choose a unique username.", name: "my-text-field-20", label: "Username")) + end # # @!endgroup @@ -220,24 +241,24 @@ def with_validation_message # # @label Auto check request ok def with_auto_check_ok - render(Primer::Alpha::TextField.new(auto_check_src: UrlHelpers.primer_view_components.example_check_ok_path, name: "my-text-field-18", label: "My text field")) + render(Primer::Alpha::TextField.new(auto_check_src: UrlHelpers.primer_view_components.example_check_ok_path, name: "my-text-field-21", label: "My text field")) end # @label Auto check request accepted def with_auto_check_accepted - render(Primer::Alpha::TextField.new(auto_check_src: UrlHelpers.primer_view_components.example_check_accepted_path, name: "my-text-field-19", label: "My text field")) + render(Primer::Alpha::TextField.new(auto_check_src: UrlHelpers.primer_view_components.example_check_accepted_path, name: "my-text-field-22", label: "My text field")) end # @label Auto check request error def with_auto_check_error - render(Primer::Alpha::TextField.new(auto_check_src: UrlHelpers.primer_view_components.example_check_error_path, name: "my-text-field-20", label: "My text field")) + render(Primer::Alpha::TextField.new(auto_check_src: UrlHelpers.primer_view_components.example_check_error_path, name: "my-text-field-23", label: "My text field")) end # # @!endgroup # @label With data target attribute def with_data_target - render(Primer::Alpha::TextField.new(name: "my-text-field", label: "My text field", data: { target: "custom-component.inputElement" })) + render(Primer::Alpha::TextField.new(name: "my-text-field-24", label: "My text field", data: { target: "custom-component.inputElement" })) end # # @!endgroup diff --git a/previews/primer/forms_preview.rb b/previews/primer/forms_preview.rb index fa1ddaa6fe..1bcdaa1ac1 100644 --- a/previews/primer/forms_preview.rb +++ b/previews/primer/forms_preview.rb @@ -67,5 +67,11 @@ def example_toggle_switch_form; end # @snapshot def auto_complete_form; end + + # @snapshot + def text_area_with_character_limit_form; end + + # @snapshot + def text_field_with_character_limit_form; end end end diff --git a/previews/primer/forms_preview/text_area_with_character_limit_form.html.erb b/previews/primer/forms_preview/text_area_with_character_limit_form.html.erb new file mode 100644 index 0000000000..e7f256b232 --- /dev/null +++ b/previews/primer/forms_preview/text_area_with_character_limit_form.html.erb @@ -0,0 +1,3 @@ +<%= primer_form_with(url: "/foo") do |f| %> + <%= render(TextAreaWithCharacterLimitForm.new(f)) %> +<% end %> diff --git a/previews/primer/forms_preview/text_field_with_character_limit_form.html.erb b/previews/primer/forms_preview/text_field_with_character_limit_form.html.erb new file mode 100644 index 0000000000..128b29eaf3 --- /dev/null +++ b/previews/primer/forms_preview/text_field_with_character_limit_form.html.erb @@ -0,0 +1,3 @@ +<%= primer_form_with(url: "/foo") do |f| %> + <%= render(TextFieldWithCharacterLimitForm.new(f)) %> +<% end %> diff --git a/static/arguments.json b/static/arguments.json index b1192f1bf4..8b6db2c6d0 100644 --- a/static/arguments.json +++ b/static/arguments.json @@ -2846,6 +2846,12 @@ "default": "N/A", "description": "When set to `true`, the field will take up all the horizontal space allowed by its container. Defaults to `true`." }, + { + "name": "character_limit", + "type": "Number", + "default": "N/A", + "description": "Optional character limit for the input. If provided, a character counter will be displayed below the input." + }, { "name": "name", "type": "String", @@ -2976,6 +2982,12 @@ "default": "N/A", "description": "When set to `true`, the field will take up all the horizontal space allowed by its container. Defaults to `true`." }, + { + "name": "character_limit", + "type": "Number", + "default": "N/A", + "description": "Optional character limit for the input. If provided, a character counter will be displayed below the input." + }, { "name": "name", "type": "String", diff --git a/static/form_previews.json b/static/form_previews.json index 20ebd3bfed..eab9b39694 100644 --- a/static/form_previews.json +++ b/static/form_previews.json @@ -107,6 +107,16 @@ "preview_path": "primer/forms/auto_complete_form", "name": "auto_complete_form", "snapshot": "true" + }, + { + "preview_path": "primer/forms/text_area_with_character_limit_form", + "name": "text_area_with_character_limit_form", + "snapshot": "true" + }, + { + "preview_path": "primer/forms/text_field_with_character_limit_form", + "name": "text_field_with_character_limit_form", + "snapshot": "true" } ] } diff --git a/static/info_arch.json b/static/info_arch.json index 804ca2603e..19485debae 100644 --- a/static/info_arch.json +++ b/static/info_arch.json @@ -8584,6 +8584,12 @@ "default": "N/A", "description": "When set to `true`, the field will take up all the horizontal space allowed by its container. Defaults to `true`." }, + { + "name": "character_limit", + "type": "Number", + "default": "N/A", + "description": "Optional character limit for the input. If provided, a character counter will be displayed below the input." + }, { "name": "name", "type": "String", @@ -8812,6 +8818,45 @@ "color-contrast" ] } + }, + { + "preview_path": "primer/alpha/text_area/with_character_limit", + "name": "with_character_limit", + "snapshot": "interactive", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/alpha/text_area/with_character_limit_over_limit", + "name": "with_character_limit_over_limit", + "snapshot": "interactive", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/alpha/text_area/with_character_limit_and_caption", + "name": "with_character_limit_and_caption", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } } ], "subcomponents": [] @@ -8842,6 +8887,12 @@ "default": "N/A", "description": "When set to `true`, the field will take up all the horizontal space allowed by its container. Defaults to `true`." }, + { + "name": "character_limit", + "type": "Number", + "default": "N/A", + "description": "Optional character limit for the input. If provided, a character counter will be displayed below the input." + }, { "name": "name", "type": "String", @@ -9288,6 +9339,45 @@ ] } }, + { + "preview_path": "primer/alpha/text_field/with_character_limit", + "name": "with_character_limit", + "snapshot": "interactive", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/alpha/text_field/with_character_limit_over_limit", + "name": "with_character_limit_over_limit", + "snapshot": "interactive", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/alpha/text_field/with_character_limit_and_caption", + "name": "with_character_limit_and_caption", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, { "preview_path": "primer/alpha/text_field/with_auto_check_ok", "name": "with_auto_check_ok", diff --git a/static/previews.json b/static/previews.json index 855e45d38e..5a4b7c9522 100644 --- a/static/previews.json +++ b/static/previews.json @@ -6989,6 +6989,45 @@ "color-contrast" ] } + }, + { + "preview_path": "primer/alpha/text_area/with_character_limit", + "name": "with_character_limit", + "snapshot": "interactive", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/alpha/text_area/with_character_limit_over_limit", + "name": "with_character_limit_over_limit", + "snapshot": "interactive", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/alpha/text_area/with_character_limit_and_caption", + "name": "with_character_limit_and_caption", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } } ] }, @@ -7284,6 +7323,45 @@ ] } }, + { + "preview_path": "primer/alpha/text_field/with_character_limit", + "name": "with_character_limit", + "snapshot": "interactive", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/alpha/text_field/with_character_limit_over_limit", + "name": "with_character_limit_over_limit", + "snapshot": "interactive", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/alpha/text_field/with_character_limit_and_caption", + "name": "with_character_limit_and_caption", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, { "preview_path": "primer/alpha/text_field/with_auto_check_ok", "name": "with_auto_check_ok", diff --git a/test/lib/primer/forms/form_control_test.rb b/test/lib/primer/forms/form_control_test.rb index 9eaf1af7d5..5bba41868f 100644 --- a/test/lib/primer/forms/form_control_test.rb +++ b/test/lib/primer/forms/form_control_test.rb @@ -54,4 +54,52 @@ def test_labels_use_custom_ids_when_provided assert_selector "input[id=bazboo]" assert_selector "label[for=bazboo]" end + + def test_character_limit_generates_aria_live_region + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |test_form| + test_form.text_field(name: :username, label: "Username", character_limit: 50) + end + end + end + + # Aria-live region exists and is configured correctly + assert_selector "span.sr-only[data-target='primer-text-field.characterLimitSrElement'][aria-live='polite']" do |element| + assert_equal "", element.text.strip + end + + assert_selector "span.FormControl-caption[data-max-length='50'] .FormControl-caption-text", text: "50 characters remaining" + end + + def test_character_limit_works_with_text_area + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |test_form| + test_form.text_area(name: :bio, label: "Bio", character_limit: 200) + end + end + end + + # Wrapped in primer-text-area custom element + assert_selector "primer-text-area" + + # Character limit elements present + assert_selector "span.FormControl-caption[data-max-length='200']" + assert_selector "span.sr-only[aria-live='polite']" + end + + def test_character_limit_with_caption_shows_both + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |test_form| + test_form.text_field(name: :title, label: "Title", caption: "Keep it short and descriptive", character_limit: 100) + end + end + end + + # Both caption text and character limit are present (as separate spans) + assert_selector "span.FormControl-caption", text: "Keep it short and descriptive" + assert_selector "span.FormControl-caption[data-max-length='100'] .FormControl-caption-text", text: "100 characters remaining" + end end diff --git a/test/lib/primer/forms/text_area_input_test.rb b/test/lib/primer/forms/text_area_input_test.rb index 4df92d3fdb..bb237e4b01 100644 --- a/test/lib/primer/forms/text_area_input_test.rb +++ b/test/lib/primer/forms/text_area_input_test.rb @@ -32,4 +32,58 @@ def test_no_error_markup refute_selector ".field_with_errors", visible: :all end + + def test_renders_character_limit_form + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render(TextAreaWithCharacterLimitForm.new(f)) + end + end + + assert_selector "primer-text-area" + assert_selector "span.FormControl-caption[data-target='primer-text-area.characterLimitElement'][data-max-length='100'] .FormControl-caption-text", text: "100 characters remaining" + assert_selector "textarea[data-target='primer-text-area.inputElement']" + assert_selector "span.sr-only[data-target='primer-text-area.characterLimitSrElement'][aria-live='polite']" + end + + def test_character_limit_rejects_zero + error = assert_raises(ArgumentError) do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |text_area_form| + text_area_form.text_area(name: :bio, label: "Bio", character_limit: 0) + end + end + end + end + + assert_includes error.message, "character_limit must be a positive integer" + end + + def test_character_limit_rejects_negative + error = assert_raises(ArgumentError) do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |text_area_form| + text_area_form.text_area(name: :bio, label: "Bio", character_limit: -10) + end + end + end + end + + assert_includes error.message, "character_limit must be a positive integer" + end + + def test_character_limit_with_caption + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |text_area_form| + text_area_form.text_area(name: :bio, label: "Bio", caption: "Tell us about yourself", character_limit: 100) + end + end + end + + assert_selector "span.FormControl-caption[data-max-length='100'] .FormControl-caption-text", text: "100 characters remaining" + assert_selector "span.FormControl-caption", text: "Tell us about yourself" + end end diff --git a/test/lib/primer/forms/text_field_input_test.rb b/test/lib/primer/forms/text_field_input_test.rb index 98f3c9d84c..f8746c9cc2 100644 --- a/test/lib/primer/forms/text_field_input_test.rb +++ b/test/lib/primer/forms/text_field_input_test.rb @@ -60,4 +60,58 @@ def test_enforces_leading_visual_when_spinner_requested assert_includes error.message, "must also specify a leading visual" end + + def test_renders_character_limit_form + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render(TextFieldWithCharacterLimitForm.new(f)) + end + end + + assert_selector "primer-text-field" + assert_selector "span.FormControl-caption[data-max-length='20'] .FormControl-caption-text", text: "20 characters remaining" + assert_selector "input[type=text][data-target*='primer-text-field.inputElement']" + assert_selector "span.sr-only[data-target='primer-text-field.characterLimitSrElement'][aria-live='polite']" + end + + def test_character_limit_rejects_zero + error = assert_raises(ArgumentError) do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |text_field_form| + text_field_form.text_field(name: :username, label: "Username", character_limit: 0) + end + end + end + end + + assert_includes error.message, "character_limit must be a positive integer" + end + + def test_character_limit_rejects_negative + error = assert_raises(ArgumentError) do + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |text_field_form| + text_field_form.text_field(name: :username, label: "Username", character_limit: -5) + end + end + end + end + + assert_includes error.message, "character_limit must be a positive integer" + end + + def test_character_limit_with_caption + render_in_view_context do + primer_form_with(url: "/foo") do |f| + render_inline_form(f) do |text_field_form| + text_field_form.text_field(name: :username, label: "Username", caption: "Choose a unique username", character_limit: 20) + end + end + end + + assert_selector "span.FormControl-caption[data-max-length='20'] .FormControl-caption-text", text: "20 characters remaining" + assert_selector "span.FormControl-caption", text: "Choose a unique username" + end end diff --git a/test/system/alpha/text_area_test.rb b/test/system/alpha/text_area_test.rb new file mode 100644 index 0000000000..e1fc2f49fc --- /dev/null +++ b/test/system/alpha/text_area_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "system/test_case" + +module Alpha + class IntegrationTextAreaTest < System::TestCase + include Primer::JsTestHelpers + + def test_character_limit_updates_on_input + visit_preview(:with_character_limit) + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "10 characters remaining" + + textarea = find("textarea[data-target='primer-text-area.inputElement']") + textarea.fill_in(with: "Hello") + + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "5 characters remaining" + end + + def test_character_limit_shows_validation_when_exceeded + visit_preview(:with_character_limit) + + textarea = find("textarea[data-target='primer-text-area.inputElement']") + textarea.fill_in(with: "Hello World!") # 12 characters + + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "2 characters over" + assert_selector "span.FormControl-caption .FormControl-caption-icon:not([hidden])", visible: :visible + assert_selector "span.FormControl-caption.fgColor-danger" + + assert_selector "textarea[invalid='true'][aria-invalid='true']" + end + + def test_character_limit_clears_validation_when_back_under_limit + visit_preview(:with_character_limit) + + textarea = find("textarea[data-target='primer-text-area.inputElement']") + textarea.fill_in(with: "Hello World!") # 12 characters + sleep 0.3 + + assert_selector "span.FormControl-caption .FormControl-caption-icon:not([hidden])", visible: :visible + assert_selector "textarea[invalid='true'][aria-invalid='true']" + + textarea.fill_in(with: "Hello") # 5 characters + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "5 characters remaining" + refute_selector "span.FormControl-caption.fgColor-danger" + + refute_selector "textarea[invalid='true']" + refute_selector "textarea[aria-invalid='true']" + end + + def test_character_limit_screen_reader_text_updates + visit_preview(:with_character_limit) + + textarea = find("textarea[data-target='primer-text-area.inputElement']") + + sr_element = find("span.sr-only[aria-live='polite']") + textarea.fill_in(with: "Test") + + # Wait for debounced update (500ms + buffer) + sleep 0.6 + + assert_equal "6 characters remaining", sr_element.text + + textarea.fill_in(with: "Hello World!") # 12 characters + sleep 0.6 + + assert_equal "2 characters over", sr_element.text + end + + def test_character_limit_singular_vs_plural + visit_preview(:with_character_limit) + + textarea = find("textarea[data-target='primer-text-area.inputElement']") + textarea.fill_in(with: "123456789") # 9 characters, limit is 10 + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "1 character remaining" + + textarea.fill_in(with: "12345678901") # 11 characters + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "1 character over" + end + + def test_character_limit_screen_reader_not_announced_on_load + visit_preview(:with_character_limit) + + sr_element = find("span.sr-only[aria-live='polite']") + + assert_equal "", sr_element.text + + textarea = find("textarea[data-target='primer-text-area.inputElement']") + textarea.fill_in(with: "Test") + + sleep 0.6 # Wait for debounced update + + assert_equal "6 characters remaining", sr_element.text + end + end +end diff --git a/test/system/alpha/text_field_test.rb b/test/system/alpha/text_field_test.rb index 69de8e3629..49f9f82f75 100644 --- a/test/system/alpha/text_field_test.rb +++ b/test/system/alpha/text_field_test.rb @@ -116,5 +116,69 @@ def test_shows_and_hides_screenreader_text refute_selector "[data-target='primer-text-field.leadingSpinner'] .sr-only" end + + def test_character_limit_updates_on_input + visit_preview(:with_character_limit) + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "10 characters remaining" + + input = find("input[type=text][data-target*='primer-text-field.inputElement']") + input.fill_in(with: "Hello") + + sleep 0.2 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "5 characters remaining" + end + + def test_character_limit_shows_validation_when_exceeded + visit_preview(:with_character_limit) + + input = find("input[type=text][data-target*='primer-text-field.inputElement']") + + input.fill_in(with: "Hello World!") # 12 characters + + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "2 characters over" + assert_selector "span.FormControl-caption .FormControl-caption-icon:not([hidden])", visible: :visible + assert_selector "span.FormControl-caption.fgColor-danger" + assert_selector "input[invalid='true'][aria-invalid='true']" + end + + def test_character_limit_clears_validation_when_back_under_limit + visit_preview(:with_character_limit) + + input = find("input[type=text][data-target*='primer-text-field.inputElement']") + + input.fill_in(with: "Hello World!") # 12 characters + sleep 0.3 + + assert_selector "input[invalid='true'][aria-invalid='true']" + + input.fill_in(with: "Hello") # 5 characters + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "5 characters remaining" + refute_selector "span.FormControl-caption.fgColor-danger" + + refute_selector "input[invalid='true']" + refute_selector "input[aria-invalid='true']" + end + + def test_character_limit_singular_vs_plural + visit_preview(:with_character_limit) + + input = find("input[type=text][data-target*='primer-text-field.inputElement']") + + input.fill_in(with: "123456789") # 9 characters, limit is 10 + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "1 character remaining" + + input.fill_in(with: "12345678901") # 11 characters + sleep 0.3 + + assert_selector "span.FormControl-caption[data-max-length='10'] .FormControl-caption-text", text: "1 character over" + end end end