Skip to content

Commit 03619a7

Browse files
Lsimmons98markdoeswork
authored andcommitted
format and sanitize zip, postal, ssn, and currency
Add mask doc example pbenhanced setup Add masking with just javascript Change md Update readme again
1 parent c7a8b24 commit 03619a7

File tree

7 files changed

+213
-5
lines changed

7 files changed

+213
-5
lines changed

playbook/app/entrypoints/playbook-rails.js

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// Text Input
2+
import PbTextInput from 'kits/pb_text_input'
3+
PbTextInput.start()
4+
15
// Forms
26
import 'kits/pb_form/pb_form_validation'
37
import formHelper from 'kits/pb_form/formHelper'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
2+
3+
4+
<%= pb_rails("title", props: { text: "Input Masks", size: 4, margin_bottom: "md" }) %>
5+
6+
<div class="pb--doc-demo-elements">
7+
<%= pb_rails("text_input", props: {
8+
label: "Currency",
9+
mask: "currency",
10+
margin_bottom: "md",
11+
name: "currency_name"
12+
}) %>
13+
14+
<%= pb_rails("text_input", props: {
15+
label: "ZIP Code",
16+
mask: "zip_code",
17+
margin_bottom: "md",
18+
}) %>
19+
20+
<%= pb_rails("text_input", props: {
21+
label: "Postal Code",
22+
mask: "postal_code",
23+
placeholder: "12345-6789",
24+
margin_bottom: "md",
25+
}) %>
26+
27+
<%= pb_rails("text_input", props: {
28+
label: "Social Security Number",
29+
mask: "ssn",
30+
margin_bottom: "md",
31+
}) %>
32+
33+
<%= pb_rails("title" , props: {
34+
text: "Hidden Input Under The Hood",
35+
padding_bottom: "sm"
36+
})%>
37+
38+
<%= pb_rails("text_input", props: {
39+
label: "Currency",
40+
mask: "currency",
41+
margin_bottom: "md",
42+
name: "currency_name",
43+
id: "example-currency"
44+
}) %>
45+
46+
<style>
47+
#example-currency-sanitized {display: flex !important;}
48+
</style>
49+
50+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The mask prop lets you style your inputs while maintaining the value that the user typed in.
2+
3+
It uses a hidden input field to submit the unformatted value as it will have the proper `name` attribute. It will also copy the id field with a `"#{your-id-sanitized}"`

playbook/app/pb_kits/playbook/pb_text_input/docs/example.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ examples:
88
- text_input_inline: Inline
99
- text_input_no_label: No Label
1010
- text_input_options: Input Options
11+
- text_input_mask: Mask
1112
react:
1213
- text_input_default: Default
1314
- text_input_error: With Error
@@ -23,4 +24,4 @@ examples:
2324
- text_input_error_swift: With Error
2425
- text_input_disabled_swift: Disabled
2526
- text_input_add_on_swift: Add On
26-
- text_input_props_swift: ""
27+
- text_input_props_swift: ""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
export default class PbTextInput {
2+
static start() {
3+
const inputElements = document.querySelectorAll('[data-pb-input-mask="true"]');
4+
5+
inputElements.forEach((inputElement) => {
6+
inputElement.addEventListener("input", (event) => {
7+
const maskType = inputElement.getAttribute("mask");
8+
const cursorPosition = inputElement.selectionStart;
9+
10+
let rawValue = event.target.value;
11+
let formattedValue = rawValue;
12+
13+
// Apply formatting based on the mask type
14+
switch (maskType) {
15+
case "currency":
16+
formattedValue = formatCurrency(rawValue);
17+
break;
18+
case "ssn":
19+
formattedValue = formatSSN(rawValue);
20+
break;
21+
case "postal_code":
22+
formattedValue = formatPostalCode(rawValue);
23+
break;
24+
case "zip_code":
25+
formattedValue = formatZipCode(rawValue);
26+
break;
27+
}
28+
29+
// Update the sanitized input field in the same wrapper
30+
const sanitizedInput = inputElement
31+
.closest(".text_input_wrapper")
32+
?.querySelector('[data="sanitized-pb-input"]');
33+
34+
if (sanitizedInput) {
35+
switch (maskType) {
36+
case "ssn":
37+
sanitizedInput.value = sanitizeSSN(formattedValue);
38+
break;
39+
case "currency":
40+
sanitizedInput.value = sanitizeCurrency(formattedValue);
41+
break;
42+
default:
43+
sanitizedInput.value = formattedValue;
44+
}
45+
}
46+
47+
inputElement.value = formattedValue;
48+
setCursorPosition(inputElement, cursorPosition, rawValue, formattedValue);
49+
});
50+
});
51+
52+
}
53+
}
54+
55+
function formatCurrency(value) {
56+
const numericValue = value.replace(/[^0-9]/g, "").slice(0, 15);
57+
58+
if (!numericValue) return "";
59+
60+
const dollars = parseFloat((parseInt(numericValue) / 100).toFixed(2));
61+
if (dollars === 0) return "";
62+
63+
return new Intl.NumberFormat("en-US", {
64+
style: "currency",
65+
currency: "USD",
66+
maximumFractionDigits: 2,
67+
}).format(dollars);
68+
}
69+
70+
function formatSSN(value) {
71+
const cleaned = value.replace(/\D/g, "").slice(0, 9);
72+
return cleaned
73+
.replace(/(\d{5})(?=\d)/, "$1-")
74+
.replace(/(\d{3})(?=\d)/, "$1-");
75+
}
76+
77+
function formatZipCode(value) {
78+
return value.replace(/\D/g, "").slice(0, 5);
79+
}
80+
81+
function formatPostalCode(value) {
82+
const cleaned = value.replace(/\D/g, "").slice(0, 9);
83+
return cleaned.replace(/(\d{5})(?=\d)/, "$1-");
84+
}
85+
86+
function sanitizeSSN(input) {
87+
return input.replace(/\D/g, "");
88+
}
89+
90+
function sanitizeCurrency(input) {
91+
return input.replace(/[$,]/g, "");
92+
}
93+
94+
// function to set cursor position
95+
function setCursorPosition(inputElement, cursorPosition, rawValue, formattedValue) {
96+
const difference = formattedValue.length - rawValue.length;
97+
98+
const newPosition = Math.max(0, cursorPosition + difference);
99+
100+
requestAnimationFrame(() => {
101+
inputElement.setSelectionRange(newPosition, newPosition);
102+
});
103+
}

playbook/app/pb_kits/playbook/pb_text_input/text_input.html.erb

+4
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313
<%= pb_rails("text_input/add_on", props: object.add_on_props) do %>
1414
<%= input_tag %>
1515
<% end %>
16+
<% elsif mask.present? %>
17+
<%= input_tag %>
18+
<%= tag(:input, data: "sanitized-pb-input", id: sanitized_id, name: object.name, style: "display: none;") %>
1619
<% else %>
1720
<%= input_tag %>
1821
<% end %>
1922
<%= pb_rails("body", props: {dark: object.dark, status: "negative", text: object.error}) if object.error %>
2023
<% end %>
2124
<% end %>
25+

playbook/app/pb_kits/playbook/pb_text_input/text_input.rb

+47-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
44
module Playbook
55
module PbTextInput
66
class TextInput < Playbook::KitBase
7+
VALID_MASKS = %w[currency zipCode postalCode ssn].freeze
8+
9+
MASK_PATTERNS = {
10+
"currency" => '^\$\d{1,3}(?:,\d{3})*(?:\.\d{2})?$',
11+
"zip_code" => '\d{5}',
12+
"postal_code" => '\d{5}-\d{4}',
13+
"ssn" => '\d{3}-\d{2}-\d{4}',
14+
}.freeze
15+
16+
MASK_PLACEHOLDERS = {
17+
"currency" => "$0.00",
18+
"zip_code" => "12345",
19+
"postal_code" => "12345-6789",
20+
"ssn" => "123-45-6789",
21+
}.freeze
22+
723
prop :autocomplete, type: Playbook::Props::Boolean,
824
default: true
925
prop :disabled, type: Playbook::Props::Boolean,
@@ -25,6 +41,9 @@ class TextInput < Playbook::KitBase
2541
prop :add_on, type: Playbook::Props::NestedProps,
2642
nested_kit: Playbook::PbTextInput::AddOn
2743

44+
prop :mask, type: Playbook::Props::String,
45+
default: nil
46+
2847
def classname
2948
default_margin_bottom = margin_bottom.present? ? "" : " mb_sm"
3049
generate_classname("pb_text_input_kit") + default_margin_bottom + error_class + inline_class
@@ -46,6 +65,10 @@ def add_on_props
4665
{ dark: dark }.merge(add_on || {})
4766
end
4867

68+
def sanitized_id
69+
"#{object.id}-sanitized" if id.present?
70+
end
71+
4972
private
5073

5174
def all_input_options
@@ -55,12 +78,13 @@ def all_input_options
5578
data: validation_data,
5679
disabled: disabled,
5780
id: input_options.dig(:id) || id,
58-
name: name,
59-
pattern: validation_pattern,
60-
placeholder: placeholder,
81+
name: mask.present? ? "" : name,
82+
pattern: validation_pattern || mask_pattern,
83+
placeholder: placeholder || mask_placeholder,
6184
required: required,
6285
type: type,
6386
value: value,
87+
mask: mask,
6488
}.merge(input_options)
6589
end
6690

@@ -75,7 +99,7 @@ def validation_pattern
7599
def validation_data
76100
fields = input_options.dig(:data) || {}
77101
fields[:message] = validation_message unless validation_message.blank?
78-
fields
102+
mask ? fields.merge(pb_input_mask: true) : fields
79103
end
80104

81105
def error_class
@@ -85,6 +109,25 @@ def error_class
85109
def inline_class
86110
inline ? " inline" : ""
87111
end
112+
113+
def mask_data
114+
return {} unless mask
115+
raise ArgumentError, "mask must be one of: #{VALID_MASKS.join(', ')}" unless VALID_MASKS.include?(mask)
116+
117+
{ mask: mask }
118+
end
119+
120+
def mask_pattern
121+
return nil unless mask
122+
123+
MASK_PATTERNS[mask]
124+
end
125+
126+
def mask_placeholder
127+
return nil unless mask
128+
129+
MASK_PLACEHOLDERS[mask]
130+
end
88131
end
89132
end
90133
end

0 commit comments

Comments
 (0)