openproject-primer_view_components 0.80.2 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -22
- data/README.md +20 -1
- data/app/assets/javascripts/components/primer/primer.d.ts +1 -0
- data/app/assets/javascripts/lib/primer/forms/character_counter.d.ts +41 -0
- data/app/assets/javascripts/lib/primer/forms/primer_text_area.d.ts +13 -0
- data/app/assets/javascripts/lib/primer/forms/primer_text_field.d.ts +2 -0
- data/app/assets/javascripts/primer_view_components.js +1 -1
- data/app/assets/javascripts/primer_view_components.js.map +1 -1
- data/app/assets/styles/primer_view_components.css +1 -1
- data/app/assets/styles/primer_view_components.css.map +1 -1
- data/app/components/primer/alpha/action_list/item.rb +2 -1
- data/app/components/primer/alpha/auto_complete/auto_complete.html.erb +8 -6
- data/app/components/primer/alpha/select_panel.rb +1 -1
- data/app/components/primer/alpha/select_panel_element.js +1 -1
- data/app/components/primer/alpha/select_panel_element.ts +1 -1
- data/app/components/primer/alpha/stack.rb +1 -0
- data/app/components/primer/alpha/tab_nav.css +1 -1
- data/app/components/primer/alpha/tab_nav.css.json +1 -0
- data/app/components/primer/alpha/tab_nav.css.map +1 -1
- data/app/components/primer/alpha/tab_nav.pcss +7 -1
- data/app/components/primer/alpha/text_area.rb +1 -0
- data/app/components/primer/alpha/text_field.rb +1 -0
- data/app/components/primer/alpha/toggle_switch.html.erb +2 -2
- data/app/components/primer/alpha/tool_tip.js +12 -5
- data/app/components/primer/alpha/tool_tip.ts +14 -5
- data/app/components/primer/beta/auto_complete/item.html.erb +5 -4
- data/app/components/primer/beta/avatar.rb +4 -0
- data/app/components/primer/beta/avatar_stack.rb +6 -0
- data/app/components/primer/beta/blankslate.css +1 -1
- data/app/components/primer/beta/blankslate.css.map +1 -1
- data/app/components/primer/beta/blankslate.pcss +2 -0
- data/app/components/primer/beta/spinner.html.erb +2 -2
- data/app/components/primer/primer.d.ts +1 -0
- data/app/components/primer/primer.js +1 -0
- data/app/components/primer/primer.ts +1 -0
- data/app/controllers/primer/view_components/toggle_switch_controller.rb +2 -2
- data/app/forms/check_box_with_nested_form.rb +9 -5
- data/app/forms/text_area_with_character_limit_form.rb +13 -0
- data/app/forms/text_field_with_character_limit_form.rb +13 -0
- data/app/lib/primer/forms/caption.html.erb +16 -9
- data/app/lib/primer/forms/character_counter.d.ts +41 -0
- data/app/lib/primer/forms/character_counter.js +114 -0
- data/app/lib/primer/forms/character_counter.ts +129 -0
- data/app/lib/primer/forms/check_box.rb +28 -0
- data/app/lib/primer/forms/dsl/input.rb +23 -0
- data/app/lib/primer/forms/dsl/multi_input.rb +3 -1
- data/app/lib/primer/forms/dsl/text_area_input.rb +12 -1
- data/app/lib/primer/forms/dsl/text_field_input.rb +11 -1
- data/app/lib/primer/forms/form_control.html.erb +2 -1
- data/app/lib/primer/forms/primer_text_area.d.ts +13 -0
- data/app/lib/primer/forms/primer_text_area.js +53 -0
- data/app/lib/primer/forms/primer_text_area.ts +37 -0
- data/app/lib/primer/forms/primer_text_field.d.ts +2 -0
- data/app/lib/primer/forms/primer_text_field.js +16 -2
- data/app/lib/primer/forms/primer_text_field.ts +16 -3
- data/app/lib/primer/forms/text_area.html.erb +6 -4
- data/app/lib/primer/forms/text_field.html.erb +1 -1
- data/app/lib/primer/forms/text_field.rb +8 -0
- data/lib/primer/accessibility.rb +9 -3
- data/lib/primer/view_components/engine.rb +1 -4
- data/lib/primer/view_components/version.rb +2 -2
- data/previews/primer/alpha/action_menu_preview/submitting_forms.html.erb +1 -1
- data/previews/primer/alpha/form_control_preview/playground.html.erb +4 -2
- data/previews/primer/alpha/octicon_symbols_preview/playground.html.erb +1 -1
- data/previews/primer/alpha/overlay_preview.rb +0 -25
- data/previews/primer/alpha/text_area_preview.rb +29 -8
- data/previews/primer/alpha/text_field_preview.rb +34 -4
- data/previews/primer/alpha/toggle_switch_preview.rb +14 -14
- data/previews/primer/alpha/tree_view_preview/loading_failure.html.erb +53 -1
- data/previews/primer/alpha/tree_view_preview/stress_test.html.erb +43 -0
- data/previews/primer/beta/button_preview/all_schemes.html.erb +1 -1
- data/previews/primer/beta/button_preview/invisible_all_visuals.html.erb +1 -1
- data/previews/primer/beta/button_preview/summary_as_button.html.erb +10 -1
- data/previews/primer/forms_preview/text_area_with_character_limit_form.html.erb +3 -0
- data/previews/primer/forms_preview/text_field_with_character_limit_form.html.erb +3 -0
- data/previews/primer/forms_preview.rb +6 -0
- data/static/arguments.json +13 -1
- data/static/form_previews.json +10 -0
- data/static/info_arch.json +106 -15
- data/static/previews.json +93 -14
- metadata +15 -2
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
constructor(inputElement, characterLimitElement, characterLimitSrElement) {
|
|
7
|
+
this.inputElement = inputElement;
|
|
8
|
+
this.characterLimitElement = characterLimitElement;
|
|
9
|
+
this.characterLimitSrElement = characterLimitSrElement;
|
|
10
|
+
this.SCREEN_READER_DELAY = 500;
|
|
11
|
+
this.announceTimeout = null;
|
|
12
|
+
this.isInitialLoad = true;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Initialize character counting by setting up event listener and initial count
|
|
16
|
+
*/
|
|
17
|
+
initialize(signal) {
|
|
18
|
+
this.inputElement.addEventListener('keyup', () => this.updateCharacterCount(), signal ? { signal } : undefined); // Keyup used over input for better screen reader support
|
|
19
|
+
this.inputElement.addEventListener('paste', () => setTimeout(() => this.updateCharacterCount(), 50), // Gives the pasted content time to register
|
|
20
|
+
signal ? { signal } : undefined);
|
|
21
|
+
this.updateCharacterCount();
|
|
22
|
+
this.isInitialLoad = false;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Clean up any pending timeouts
|
|
26
|
+
*/
|
|
27
|
+
cleanup() {
|
|
28
|
+
if (this.announceTimeout) {
|
|
29
|
+
clearTimeout(this.announceTimeout);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Pluralizes a word based on the count
|
|
34
|
+
*/
|
|
35
|
+
pluralize(count, string) {
|
|
36
|
+
return count === 1 ? string : `${string}s`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Update the character count display and validation state
|
|
40
|
+
*/
|
|
41
|
+
updateCharacterCount() {
|
|
42
|
+
if (!this.characterLimitElement)
|
|
43
|
+
return;
|
|
44
|
+
const maxLengthAttr = this.characterLimitElement.getAttribute('data-max-length');
|
|
45
|
+
if (!maxLengthAttr)
|
|
46
|
+
return;
|
|
47
|
+
const maxLength = parseInt(maxLengthAttr, 10);
|
|
48
|
+
const currentLength = this.inputElement.value.length;
|
|
49
|
+
const charactersRemaining = maxLength - currentLength;
|
|
50
|
+
let message = '';
|
|
51
|
+
if (charactersRemaining >= 0) {
|
|
52
|
+
const characterText = this.pluralize(charactersRemaining, 'character');
|
|
53
|
+
message = `${charactersRemaining} ${characterText} remaining`;
|
|
54
|
+
const textSpan = this.characterLimitElement.querySelector('.FormControl-caption-text');
|
|
55
|
+
if (textSpan) {
|
|
56
|
+
textSpan.textContent = message;
|
|
57
|
+
}
|
|
58
|
+
this.clearError();
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const charactersOver = -charactersRemaining;
|
|
62
|
+
const characterText = this.pluralize(charactersOver, 'character');
|
|
63
|
+
message = `${charactersOver} ${characterText} over`;
|
|
64
|
+
const textSpan = this.characterLimitElement.querySelector('.FormControl-caption-text');
|
|
65
|
+
if (textSpan) {
|
|
66
|
+
textSpan.textContent = message;
|
|
67
|
+
}
|
|
68
|
+
this.setError();
|
|
69
|
+
}
|
|
70
|
+
// We don't want this announced on initial load
|
|
71
|
+
if (!this.isInitialLoad) {
|
|
72
|
+
this.announceToScreenReader(message);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Announce character count to screen readers with debouncing
|
|
77
|
+
*/
|
|
78
|
+
announceToScreenReader(message) {
|
|
79
|
+
if (this.announceTimeout) {
|
|
80
|
+
clearTimeout(this.announceTimeout);
|
|
81
|
+
}
|
|
82
|
+
this.announceTimeout = window.setTimeout(() => {
|
|
83
|
+
if (this.characterLimitSrElement) {
|
|
84
|
+
this.characterLimitSrElement.textContent = message;
|
|
85
|
+
}
|
|
86
|
+
}, this.SCREEN_READER_DELAY);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Set error when character limit is exceeded
|
|
90
|
+
*/
|
|
91
|
+
setError() {
|
|
92
|
+
this.inputElement.setAttribute('invalid', 'true');
|
|
93
|
+
this.inputElement.setAttribute('aria-invalid', 'true');
|
|
94
|
+
this.characterLimitElement.classList.add('fgColor-danger');
|
|
95
|
+
// Show danger icon
|
|
96
|
+
const icon = this.characterLimitElement.querySelector('.FormControl-caption-icon');
|
|
97
|
+
if (icon) {
|
|
98
|
+
icon.removeAttribute('hidden');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Clear error when back under character limit
|
|
103
|
+
*/
|
|
104
|
+
clearError() {
|
|
105
|
+
this.inputElement.removeAttribute('invalid');
|
|
106
|
+
this.inputElement.removeAttribute('aria-invalid');
|
|
107
|
+
this.characterLimitElement.classList.remove('fgColor-danger');
|
|
108
|
+
// Hide danger icon
|
|
109
|
+
const icon = this.characterLimitElement.querySelector('.FormControl-caption-icon');
|
|
110
|
+
if (icon) {
|
|
111
|
+
icon.setAttribute('hidden', '');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -11,6 +11,13 @@ module Primer
|
|
|
11
11
|
@input.add_label_classes("FormControl-label")
|
|
12
12
|
@input.add_input_classes("FormControl-checkbox")
|
|
13
13
|
|
|
14
|
+
# Generate custom ID that preserves brackets from the name
|
|
15
|
+
unless @input.input_arguments[:id].present?
|
|
16
|
+
generate_custom_id
|
|
17
|
+
# Update the label's for attribute to match the new ID
|
|
18
|
+
@input.label_arguments[:for] = @input.input_arguments[:id]
|
|
19
|
+
end
|
|
20
|
+
|
|
14
21
|
return unless @input.scheme == :array
|
|
15
22
|
|
|
16
23
|
@input.input_arguments[:multiple] = true
|
|
@@ -32,6 +39,27 @@ module Primer
|
|
|
32
39
|
|
|
33
40
|
private
|
|
34
41
|
|
|
42
|
+
def generate_custom_id
|
|
43
|
+
# Generate an ID from the name that preserves special characters like brackets
|
|
44
|
+
# For array scheme: name + "_" + value (e.g., "permissions[3]_foo")
|
|
45
|
+
# For boolean scheme: just the name (e.g., "long_o")
|
|
46
|
+
base_name = @input.name.to_s
|
|
47
|
+
|
|
48
|
+
# For array scheme, Rails appends [] to the name, so we remove it for ID generation
|
|
49
|
+
# but only the trailing [] that Rails adds, not brackets that are part of the original name
|
|
50
|
+
# Regex /\[\]$/ matches literal "[]" at the end of the string
|
|
51
|
+
base_name = base_name.sub(/\[\]$/, "")
|
|
52
|
+
|
|
53
|
+
# For array scheme, append the value to make IDs unique
|
|
54
|
+
# For boolean scheme, just use the base name
|
|
55
|
+
# Note: Rails automatically escapes HTML attributes, so special characters are safe
|
|
56
|
+
if @input.scheme == :array && @input.value.present?
|
|
57
|
+
@input.input_arguments[:id] = "#{base_name}_#{@input.value}"
|
|
58
|
+
else
|
|
59
|
+
@input.input_arguments[:id] = base_name
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
35
63
|
def checked_value
|
|
36
64
|
@input.value || "1"
|
|
37
65
|
end
|
|
@@ -33,6 +33,9 @@ module Primer
|
|
|
33
33
|
# @!macro [new] form_full_width_arguments
|
|
34
34
|
# @param full_width [Boolean] When set to `true`, the field will take up all the horizontal space allowed by its container. Defaults to `true`.
|
|
35
35
|
|
|
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
|
+
|
|
36
39
|
# @!macro [new] form_input_width_arguments
|
|
37
40
|
# @param input_width [Symbol] The width of the field. <%= one_of(Primer::Forms::Dsl::Input::INPUT_WIDTH_OPTIONS) %>
|
|
38
41
|
|
|
@@ -128,6 +131,7 @@ module Primer
|
|
|
128
131
|
|
|
129
132
|
@ids = {}.tap do |id_map|
|
|
130
133
|
id_map[:validation] = "validation-#{@base_id}" if supports_validation?
|
|
134
|
+
id_map[:character_limit_caption] = "character_limit-#{@base_id}" if character_limit?
|
|
131
135
|
id_map[:caption] = "caption-#{@base_id}" if caption? || caption_template?
|
|
132
136
|
end
|
|
133
137
|
|
|
@@ -213,6 +217,25 @@ module Primer
|
|
|
213
217
|
form.render_caption_template(caption_template_name)
|
|
214
218
|
end
|
|
215
219
|
|
|
220
|
+
def character_limit?
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def character_limit_id
|
|
225
|
+
ids[:character_limit_caption]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def character_limit_target_prefix
|
|
229
|
+
case type
|
|
230
|
+
when :text_field
|
|
231
|
+
"primer-text-field"
|
|
232
|
+
when :text_area
|
|
233
|
+
"primer-text-area"
|
|
234
|
+
else
|
|
235
|
+
""
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
216
239
|
def valid?
|
|
217
240
|
supports_validation? && validation_messages.empty? && !@invalid
|
|
218
241
|
end
|
|
@@ -39,7 +39,9 @@ module Primer
|
|
|
39
39
|
new_options[:data] ||= {}
|
|
40
40
|
new_options[:data][:name] = name
|
|
41
41
|
new_options[:data][:targets] = "primer-multi-input.fields"
|
|
42
|
-
new_options[:id] =
|
|
42
|
+
new_options[:id] = "#{@name}_#{name}"
|
|
43
|
+
new_options[:aria] ||= {}
|
|
44
|
+
new_options[:aria][:labelledby] = "label-#{base_id}"
|
|
43
45
|
new_options[:disabled] = true if options[:hidden] # disable to avoid submitting to server
|
|
44
46
|
new_options
|
|
45
47
|
end
|
|
@@ -5,13 +5,20 @@ module Primer
|
|
|
5
5
|
module Dsl
|
|
6
6
|
# :nodoc:
|
|
7
7
|
class TextAreaInput < Input
|
|
8
|
-
attr_reader :name, :label
|
|
8
|
+
attr_reader :name, :label, :character_limit
|
|
9
9
|
|
|
10
10
|
def initialize(name:, label:, **system_arguments)
|
|
11
11
|
@name = name
|
|
12
12
|
@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
|
|
13
18
|
|
|
14
19
|
super(**system_arguments)
|
|
20
|
+
|
|
21
|
+
add_input_data(:target, "primer-text-area.inputElement")
|
|
15
22
|
end
|
|
16
23
|
|
|
17
24
|
def to_component
|
|
@@ -22,6 +29,10 @@ module Primer
|
|
|
22
29
|
:text_area
|
|
23
30
|
end
|
|
24
31
|
|
|
32
|
+
def character_limit?
|
|
33
|
+
@character_limit.present?
|
|
34
|
+
end
|
|
35
|
+
|
|
25
36
|
# :nocov:
|
|
26
37
|
def focusable?
|
|
27
38
|
true
|
|
@@ -6,7 +6,7 @@ module Primer
|
|
|
6
6
|
attr_reader(
|
|
7
7
|
*%i[
|
|
8
8
|
name label show_clear_button leading_visual leading_spinner trailing_visual clear_button_id
|
|
9
|
-
visually_hide_label inset monospace field_wrap_classes auto_check_src
|
|
9
|
+
clear_button_label visually_hide_label inset monospace field_wrap_classes auto_check_src character_limit
|
|
10
10
|
]
|
|
11
11
|
)
|
|
12
12
|
|
|
@@ -21,9 +21,15 @@ module Primer
|
|
|
21
21
|
@trailing_visual = system_arguments.delete(:trailing_visual)
|
|
22
22
|
@leading_spinner = !!system_arguments.delete(:leading_spinner)
|
|
23
23
|
@clear_button_id = system_arguments.delete(:clear_button_id) || SecureRandom.uuid
|
|
24
|
+
@clear_button_label = system_arguments.delete(:clear_button_label)
|
|
24
25
|
@inset = system_arguments.delete(:inset)
|
|
25
26
|
@monospace = system_arguments.delete(:monospace)
|
|
26
27
|
@auto_check_src = system_arguments.delete(:auto_check_src)
|
|
28
|
+
@character_limit = system_arguments.delete(:character_limit)
|
|
29
|
+
|
|
30
|
+
if @character_limit.present? && @character_limit.to_i <= 0
|
|
31
|
+
raise ArgumentError, "character_limit must be a positive integer, got #{@character_limit}"
|
|
32
|
+
end
|
|
27
33
|
|
|
28
34
|
if @leading_visual
|
|
29
35
|
@leading_visual[:classes] = class_names(
|
|
@@ -69,6 +75,10 @@ module Primer
|
|
|
69
75
|
true
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
def character_limit?
|
|
79
|
+
@character_limit.present?
|
|
80
|
+
end
|
|
81
|
+
|
|
72
82
|
def validation_arguments
|
|
73
83
|
if auto_check_src.present?
|
|
74
84
|
super.merge(
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<% if @input.form_control? %>
|
|
2
2
|
<%= content_tag(@tag, **@form_group_arguments) do %>
|
|
3
3
|
<% if @input.label %>
|
|
4
|
-
|
|
4
|
+
<% label_id = @input.label_arguments[:id] || "label-#{@input.base_id}" %>
|
|
5
|
+
<%= builder.label(@input.name, **@input.label_arguments.merge(id: label_id)) do %>
|
|
5
6
|
<%= @input.label %>
|
|
6
7
|
<% if @input.required? %>
|
|
7
8
|
<span aria-hidden="true">*</span>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class PrimerTextAreaElement extends HTMLElement {
|
|
2
|
+
#private;
|
|
3
|
+
inputElement: HTMLTextAreaElement;
|
|
4
|
+
characterLimitElement: HTMLElement;
|
|
5
|
+
characterLimitSrElement: HTMLElement;
|
|
6
|
+
connectedCallback(): void;
|
|
7
|
+
disconnectedCallback(): void;
|
|
8
|
+
}
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
PrimerTextAreaElement: typeof PrimerTextAreaElement;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
8
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
9
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
10
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
11
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
12
|
+
};
|
|
13
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
14
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
15
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
16
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
17
|
+
};
|
|
18
|
+
var _PrimerTextAreaElement_characterCounter;
|
|
19
|
+
import { controller, target } from '@github/catalyst';
|
|
20
|
+
import { CharacterCounter } from './character_counter';
|
|
21
|
+
let PrimerTextAreaElement = class PrimerTextAreaElement extends HTMLElement {
|
|
22
|
+
constructor() {
|
|
23
|
+
super(...arguments);
|
|
24
|
+
_PrimerTextAreaElement_characterCounter.set(this, null);
|
|
25
|
+
}
|
|
26
|
+
connectedCallback() {
|
|
27
|
+
if (this.characterLimitElement) {
|
|
28
|
+
__classPrivateFieldSet(this, _PrimerTextAreaElement_characterCounter, new CharacterCounter(this.inputElement, this.characterLimitElement, this.characterLimitSrElement), "f");
|
|
29
|
+
__classPrivateFieldGet(this, _PrimerTextAreaElement_characterCounter, "f").initialize();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
disconnectedCallback() {
|
|
33
|
+
__classPrivateFieldGet(this, _PrimerTextAreaElement_characterCounter, "f")?.cleanup();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
_PrimerTextAreaElement_characterCounter = new WeakMap();
|
|
37
|
+
__decorate([
|
|
38
|
+
target
|
|
39
|
+
], PrimerTextAreaElement.prototype, "inputElement", void 0);
|
|
40
|
+
__decorate([
|
|
41
|
+
target
|
|
42
|
+
], PrimerTextAreaElement.prototype, "characterLimitElement", void 0);
|
|
43
|
+
__decorate([
|
|
44
|
+
target
|
|
45
|
+
], PrimerTextAreaElement.prototype, "characterLimitSrElement", void 0);
|
|
46
|
+
PrimerTextAreaElement = __decorate([
|
|
47
|
+
controller
|
|
48
|
+
], PrimerTextAreaElement);
|
|
49
|
+
export { PrimerTextAreaElement };
|
|
50
|
+
if (!window.customElements.get('primer-text-area')) {
|
|
51
|
+
Object.assign(window, { PrimerTextAreaElement });
|
|
52
|
+
window.customElements.define('primer-text-area', PrimerTextAreaElement);
|
|
53
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {controller, target} from '@github/catalyst'
|
|
2
|
+
import {CharacterCounter} from './character_counter'
|
|
3
|
+
|
|
4
|
+
@controller
|
|
5
|
+
export class PrimerTextAreaElement extends HTMLElement {
|
|
6
|
+
@target inputElement: HTMLTextAreaElement
|
|
7
|
+
@target characterLimitElement: HTMLElement
|
|
8
|
+
@target characterLimitSrElement: HTMLElement
|
|
9
|
+
|
|
10
|
+
#characterCounter: CharacterCounter | null = null
|
|
11
|
+
|
|
12
|
+
connectedCallback(): void {
|
|
13
|
+
if (this.characterLimitElement) {
|
|
14
|
+
this.#characterCounter = new CharacterCounter(
|
|
15
|
+
this.inputElement,
|
|
16
|
+
this.characterLimitElement,
|
|
17
|
+
this.characterLimitSrElement,
|
|
18
|
+
)
|
|
19
|
+
this.#characterCounter.initialize()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
disconnectedCallback(): void {
|
|
24
|
+
this.#characterCounter?.cleanup()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare global {
|
|
29
|
+
interface Window {
|
|
30
|
+
PrimerTextAreaElement: typeof PrimerTextAreaElement
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!window.customElements.get('primer-text-area')) {
|
|
35
|
+
Object.assign(window, {PrimerTextAreaElement})
|
|
36
|
+
window.customElements.define('primer-text-area', PrimerTextAreaElement)
|
|
37
|
+
}
|
|
@@ -15,6 +15,8 @@ export declare class PrimerTextFieldElement extends HTMLElement {
|
|
|
15
15
|
validationErrorIcon: HTMLElement;
|
|
16
16
|
leadingVisual: HTMLElement;
|
|
17
17
|
leadingSpinner: HTMLElement;
|
|
18
|
+
characterLimitElement: HTMLElement;
|
|
19
|
+
characterLimitSrElement: HTMLElement;
|
|
18
20
|
connectedCallback(): void;
|
|
19
21
|
disconnectedCallback(): void;
|
|
20
22
|
clearContents(): void;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable custom-elements/expose-class-on-global */
|
|
2
1
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
2
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
3
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -16,13 +15,15 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
|
|
|
16
15
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
17
16
|
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
18
17
|
};
|
|
19
|
-
var _PrimerTextFieldElement_abortController;
|
|
18
|
+
var _PrimerTextFieldElement_abortController, _PrimerTextFieldElement_characterCounter;
|
|
20
19
|
import '@github/auto-check-element';
|
|
21
20
|
import { controller, target } from '@github/catalyst';
|
|
21
|
+
import { CharacterCounter } from './character_counter';
|
|
22
22
|
let PrimerTextFieldElement = class PrimerTextFieldElement extends HTMLElement {
|
|
23
23
|
constructor() {
|
|
24
24
|
super(...arguments);
|
|
25
25
|
_PrimerTextFieldElement_abortController.set(this, void 0);
|
|
26
|
+
_PrimerTextFieldElement_characterCounter.set(this, null);
|
|
26
27
|
}
|
|
27
28
|
connectedCallback() {
|
|
28
29
|
__classPrivateFieldGet(this, _PrimerTextFieldElement_abortController, "f")?.abort();
|
|
@@ -40,9 +41,15 @@ let PrimerTextFieldElement = class PrimerTextFieldElement extends HTMLElement {
|
|
|
40
41
|
const errorMessage = await event.detail.response.text();
|
|
41
42
|
this.setError(errorMessage);
|
|
42
43
|
}, { signal });
|
|
44
|
+
// Set up character limit tracking if present
|
|
45
|
+
if (this.characterLimitElement) {
|
|
46
|
+
__classPrivateFieldSet(this, _PrimerTextFieldElement_characterCounter, new CharacterCounter(this.inputElement, this.characterLimitElement, this.characterLimitSrElement), "f");
|
|
47
|
+
__classPrivateFieldGet(this, _PrimerTextFieldElement_characterCounter, "f").initialize(signal);
|
|
48
|
+
}
|
|
43
49
|
}
|
|
44
50
|
disconnectedCallback() {
|
|
45
51
|
__classPrivateFieldGet(this, _PrimerTextFieldElement_abortController, "f")?.abort();
|
|
52
|
+
__classPrivateFieldGet(this, _PrimerTextFieldElement_characterCounter, "f")?.cleanup();
|
|
46
53
|
}
|
|
47
54
|
clearContents() {
|
|
48
55
|
this.inputElement.value = '';
|
|
@@ -92,6 +99,7 @@ let PrimerTextFieldElement = class PrimerTextFieldElement extends HTMLElement {
|
|
|
92
99
|
}
|
|
93
100
|
};
|
|
94
101
|
_PrimerTextFieldElement_abortController = new WeakMap();
|
|
102
|
+
_PrimerTextFieldElement_characterCounter = new WeakMap();
|
|
95
103
|
__decorate([
|
|
96
104
|
target
|
|
97
105
|
], PrimerTextFieldElement.prototype, "inputElement", void 0);
|
|
@@ -113,6 +121,12 @@ __decorate([
|
|
|
113
121
|
__decorate([
|
|
114
122
|
target
|
|
115
123
|
], PrimerTextFieldElement.prototype, "leadingSpinner", void 0);
|
|
124
|
+
__decorate([
|
|
125
|
+
target
|
|
126
|
+
], PrimerTextFieldElement.prototype, "characterLimitElement", void 0);
|
|
127
|
+
__decorate([
|
|
128
|
+
target
|
|
129
|
+
], PrimerTextFieldElement.prototype, "characterLimitSrElement", void 0);
|
|
116
130
|
PrimerTextFieldElement = __decorate([
|
|
117
131
|
controller
|
|
118
132
|
], PrimerTextFieldElement);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
/* eslint-disable custom-elements/expose-class-on-global */
|
|
2
|
-
|
|
3
1
|
import '@github/auto-check-element'
|
|
4
2
|
import type {AutoCheckErrorEvent, AutoCheckSuccessEvent} from '@github/auto-check-element'
|
|
5
3
|
import {controller, target} from '@github/catalyst'
|
|
4
|
+
import {CharacterCounter} from './character_counter'
|
|
6
5
|
|
|
7
6
|
declare global {
|
|
8
7
|
interface HTMLElementEventMap {
|
|
@@ -20,8 +19,11 @@ export class PrimerTextFieldElement extends HTMLElement {
|
|
|
20
19
|
@target validationErrorIcon: HTMLElement
|
|
21
20
|
@target leadingVisual: HTMLElement
|
|
22
21
|
@target leadingSpinner: HTMLElement
|
|
22
|
+
@target characterLimitElement: HTMLElement
|
|
23
|
+
@target characterLimitSrElement: HTMLElement
|
|
23
24
|
|
|
24
25
|
#abortController: AbortController | null
|
|
26
|
+
#characterCounter: CharacterCounter | null = null
|
|
25
27
|
|
|
26
28
|
connectedCallback(): void {
|
|
27
29
|
this.#abortController?.abort()
|
|
@@ -48,16 +50,27 @@ export class PrimerTextFieldElement extends HTMLElement {
|
|
|
48
50
|
},
|
|
49
51
|
{signal},
|
|
50
52
|
)
|
|
53
|
+
|
|
54
|
+
// Set up character limit tracking if present
|
|
55
|
+
if (this.characterLimitElement) {
|
|
56
|
+
this.#characterCounter = new CharacterCounter(
|
|
57
|
+
this.inputElement,
|
|
58
|
+
this.characterLimitElement,
|
|
59
|
+
this.characterLimitSrElement,
|
|
60
|
+
)
|
|
61
|
+
this.#characterCounter.initialize(signal)
|
|
62
|
+
}
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
disconnectedCallback() {
|
|
54
66
|
this.#abortController?.abort()
|
|
67
|
+
this.#characterCounter?.cleanup()
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
clearContents() {
|
|
58
71
|
this.inputElement.value = ''
|
|
59
72
|
this.inputElement.focus()
|
|
60
|
-
this.inputElement.dispatchEvent(new Event('input', {
|
|
73
|
+
this.inputElement.dispatchEvent(new Event('input', {bubbles: true, cancelable: false}))
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
clearError(): void {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
<%=
|
|
3
|
-
<%=
|
|
1
|
+
<primer-text-area>
|
|
2
|
+
<%= render(FormControl.new(input: @input)) do %>
|
|
3
|
+
<%= content_tag(:div, **@field_wrap_arguments) do %>
|
|
4
|
+
<%= builder.text_area(@input.name, **@input.input_arguments) %>
|
|
5
|
+
<% end %>
|
|
4
6
|
<% end %>
|
|
5
|
-
|
|
7
|
+
</primer-text-area>
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<%= builder.text_field(@input.name, **@input.input_arguments) %>
|
|
14
14
|
<% end %>
|
|
15
15
|
<% if @input.show_clear_button? %>
|
|
16
|
-
<button type="button" id="<%= @input.clear_button_id %>" class="FormControl-input-trailingAction" aria-label="Clear" data-action="click:primer-text-field#clearContents">
|
|
16
|
+
<button type="button" id="<%= @input.clear_button_id %>" class="FormControl-input-trailingAction" aria-label="<%= @input.clear_button_label || 'Clear' %>" data-action="click:primer-text-field#clearContents">
|
|
17
17
|
<%= render(Primer::Beta::Octicon.new(icon: :"x-circle-fill")) %>
|
|
18
18
|
</button>
|
|
19
19
|
<% end %>
|