Skip to content

Commit ef05ebd

Browse files
[PLAY-1772] Rails input masking (#4127)
Runway https://runway.powerhrg.com/backlog_items/PLAY-1772 Original Branch had unverified commits https://github.com/powerhome/playbook/pull/4114/commits Adds input masking to the text input so that there are dashes and commas and all other sorts of patterns in the ui for currency social security number zip code postal code I put all the javascript for the masking logic in `index.js` in the kit's folder **How to test?** Steps to confirm the desired behavior: 1. Go to https://pr4127.playbook.beta.px.powerapp.cloud/kits/text_input/rails#mask 2. Type into the text inputs ![screenshot-pr4127_playbook_beta_px_powerapp_cloud-2025_01_15-12_26_12](https://github.com/user-attachments/assets/824ca8e0-82ad-4b4a-9094-8bdb3ca47281) --------- Co-authored-by: Liam Simmons <[email protected]>
1 parent dee9984 commit ef05ebd

File tree

8 files changed

+218
-4
lines changed

8 files changed

+218
-4
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,46 @@
1+
<%= pb_rails("text_input", props: {
2+
label: "Currency",
3+
mask: "currency",
4+
margin_bottom: "md",
5+
name: "currency_name",
6+
placeholder:"$0.00"
7+
}) %>
8+
9+
<%= pb_rails("text_input", props: {
10+
label: "ZIP Code",
11+
mask: "zip_code",
12+
margin_bottom: "md",
13+
placeholder: "12345"
14+
}) %>
15+
16+
<%= pb_rails("text_input", props: {
17+
label: "Postal Code",
18+
mask: "postal_code",
19+
placeholder: "12345-6789",
20+
margin_bottom: "md",
21+
}) %>
22+
23+
<%= pb_rails("text_input", props: {
24+
label: "SSN",
25+
mask: "ssn",
26+
margin_bottom: "md",
27+
placeholder: "123-45-6789"
28+
}) %>
29+
30+
<%= pb_rails("title" , props: {
31+
text: "Hidden Input Under The Hood",
32+
padding_bottom: "sm"
33+
})%>
34+
35+
<%= pb_rails("text_input", props: {
36+
label: "Currency",
37+
mask: "currency",
38+
margin_bottom: "md",
39+
name: "currency_name",
40+
id: "example-currency",
41+
placeholder: "$0.00",
42+
}) %>
43+
44+
<style>
45+
#example-currency-sanitized {display: flex !important;}
46+
</style>
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

+33-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
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+
716
prop :autocomplete, type: Playbook::Props::Boolean,
817
default: true
918
prop :disabled, type: Playbook::Props::Boolean,
@@ -25,6 +34,9 @@ class TextInput < Playbook::KitBase
2534
prop :add_on, type: Playbook::Props::NestedProps,
2635
nested_kit: Playbook::PbTextInput::AddOn
2736

37+
prop :mask, type: Playbook::Props::String,
38+
default: nil
39+
2840
def classname
2941
default_margin_bottom = margin_bottom.present? ? "" : " mb_sm"
3042
generate_classname("pb_text_input_kit") + default_margin_bottom + error_class + inline_class
@@ -46,6 +58,10 @@ def add_on_props
4658
{ dark: dark }.merge(add_on || {})
4759
end
4860

61+
def sanitized_id
62+
"#{object.id}-sanitized" if id.present?
63+
end
64+
4965
private
5066

5167
def all_input_options
@@ -55,12 +71,13 @@ def all_input_options
5571
data: validation_data,
5672
disabled: disabled,
5773
id: input_options.dig(:id) || id,
58-
name: name,
59-
pattern: validation_pattern,
74+
name: mask.present? ? "" : name,
75+
pattern: validation_pattern || mask_pattern,
6076
placeholder: placeholder,
6177
required: required,
6278
type: type,
6379
value: value,
80+
mask: mask,
6481
}.merge(input_options)
6582
end
6683

@@ -75,7 +92,7 @@ def validation_pattern
7592
def validation_data
7693
fields = input_options.dig(:data) || {}
7794
fields[:message] = validation_message unless validation_message.blank?
78-
fields
95+
mask ? fields.merge(pb_input_mask: true) : fields
7996
end
8097

8198
def error_class
@@ -85,6 +102,19 @@ def error_class
85102
def inline_class
86103
inline ? " inline" : ""
87104
end
105+
106+
def mask_data
107+
return {} unless mask
108+
raise ArgumentError, "mask must be one of: #{VALID_MASKS.join(', ')}" unless VALID_MASKS.include?(mask)
109+
110+
{ mask: mask }
111+
end
112+
113+
def mask_pattern
114+
return nil unless mask
115+
116+
MASK_PATTERNS[mask]
117+
end
88118
end
89119
end
90120
end

playbook/spec/pb_kits/playbook/kits/text_input_spec.rb

+23
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
it { is_expected.to define_prop(:value) }
1717
it { is_expected.to define_prop(:type).with_default("text") }
1818
it { is_expected.to define_hash_prop(:input_options).with_default({}) }
19+
it { is_expected.to define_prop(:mask).with_default(nil) }
1920

2021
describe "#classname" do
2122
it "returns namespaced class name", :aggregate_failures do
@@ -28,4 +29,26 @@
2829
expect(subject.new({ margin_bottom: "lg" }).classname).to eq "pb_text_input_kit mb_lg"
2930
end
3031
end
32+
33+
describe "#mask" do
34+
context "with a valid mask" do
35+
it "sets data-pb-input-mask attribute and mask prop" do
36+
text_input = subject.new(mask: "currency")
37+
expect(text_input.input_tag).to include('data-pb-input-mask="true"')
38+
expect(text_input.input_tag).to include('mask="currency"')
39+
end
40+
41+
it "sets the correct pattern for currency" do
42+
text_input = subject.new(mask: "currency")
43+
expect(text_input.input_tag).to include('pattern="^\\$\\d{1,3}(?:,\\d{3})*(?:\\.\\d{2})?$"')
44+
end
45+
end
46+
47+
context "without a mask" do
48+
it "does not set data-pb-input-mask" do
49+
text_input = subject.new(mask: nil)
50+
expect(text_input.input_tag).not_to include("data-pb-input-mask")
51+
end
52+
end
53+
end
3154
end

0 commit comments

Comments
 (0)