playbook_ui 15.7.0 → 15.8.0.pre.rc.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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/_playbook.scss +1 -1
  3. data/app/pb_kits/playbook/pb_fixed_confirmation_toast/fixed_confirmation_toast.rb +9 -7
  4. data/app/pb_kits/playbook/pb_fixed_confirmation_toast/index.js +3 -8
  5. data/app/pb_kits/playbook/pb_form/docs/_form_form_with.html.erb +1 -1
  6. data/app/pb_kits/playbook/pb_form/docs/_form_form_with_validate.html.erb +2 -1
  7. data/app/pb_kits/playbook/pb_form/docs/_form_with_required_indicator.html.erb +14 -0
  8. data/app/pb_kits/playbook/pb_form/docs/_form_with_required_indicator.md +3 -0
  9. data/app/pb_kits/playbook/pb_form/docs/example.yml +1 -0
  10. data/app/pb_kits/playbook/pb_popover/docs/_popover_append_to.html.erb +2 -2
  11. data/app/pb_kits/playbook/pb_popover/docs/_popover_append_to.jsx +3 -2
  12. data/app/pb_kits/playbook/pb_text_input/_text_input.tsx +56 -6
  13. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.html.erb +7 -0
  14. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.jsx +24 -0
  15. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.md +2 -0
  16. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_required_indicator.html.erb +6 -0
  17. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_required_indicator.jsx +25 -0
  18. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_required_indicator.md +3 -0
  19. data/app/pb_kits/playbook/pb_text_input/docs/example.yml +5 -0
  20. data/app/pb_kits/playbook/pb_text_input/docs/index.js +2 -0
  21. data/app/pb_kits/playbook/pb_text_input/index.js +49 -8
  22. data/app/pb_kits/playbook/pb_text_input/text_input.html.erb +6 -0
  23. data/app/pb_kits/playbook/pb_text_input/text_input.rb +7 -1
  24. data/app/pb_kits/playbook/pb_text_input/text_input.test.js +69 -0
  25. data/app/pb_kits/playbook/pb_textarea/_textarea.tsx +38 -2
  26. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.html.erb +5 -0
  27. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.jsx +24 -0
  28. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.md +1 -0
  29. data/app/pb_kits/playbook/pb_textarea/docs/example.yml +2 -0
  30. data/app/pb_kits/playbook/pb_textarea/docs/index.js +1 -0
  31. data/app/pb_kits/playbook/pb_textarea/index.ts +62 -5
  32. data/app/pb_kits/playbook/pb_textarea/textarea.html.erb +1 -0
  33. data/app/pb_kits/playbook/pb_textarea/textarea.rb +8 -0
  34. data/app/pb_kits/playbook/pb_textarea/textarea.test.js +57 -2
  35. data/app/pb_kits/playbook/pb_time_picker/_time_picker.scss +296 -0
  36. data/app/pb_kits/playbook/pb_time_picker/_time_picker.tsx +822 -0
  37. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_24_hour.html.erb +2 -0
  38. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_24_hour.jsx +16 -0
  39. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_24_hour.md +1 -0
  40. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_default.html.erb +1 -0
  41. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_default.jsx +13 -0
  42. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_default.md +1 -0
  43. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_default_time.html.erb +4 -0
  44. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_default_time.jsx +29 -0
  45. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_default_time.md +1 -0
  46. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_disabled.html.erb +13 -0
  47. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_disabled.jsx +23 -0
  48. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_error.html.erb +5 -0
  49. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_error.jsx +15 -0
  50. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_input_options.html.erb +14 -0
  51. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_label.html.erb +2 -0
  52. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_label.jsx +15 -0
  53. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_min_max_time.html.erb +42 -0
  54. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_min_max_time.jsx +52 -0
  55. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_min_max_time.md +1 -0
  56. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_on_handler.jsx +45 -0
  57. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_on_handler.md +1 -0
  58. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_timezone.html.erb +3 -0
  59. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_timezone.jsx +21 -0
  60. data/app/pb_kits/playbook/pb_time_picker/docs/_time_picker_timezone.md +1 -0
  61. data/app/pb_kits/playbook/pb_time_picker/docs/example.yml +24 -0
  62. data/app/pb_kits/playbook/pb_time_picker/docs/index.js +9 -0
  63. data/app/pb_kits/playbook/pb_time_picker/index.ts +40 -0
  64. data/app/pb_kits/playbook/pb_time_picker/time_picker.html.erb +1 -0
  65. data/app/pb_kits/playbook/pb_time_picker/time_picker.rb +80 -0
  66. data/app/pb_kits/playbook/pb_time_picker/time_picker.test.jsx +114 -0
  67. data/app/pb_kits/playbook/pb_time_picker/time_picker_helper.ts +662 -0
  68. data/app/pb_kits/playbook/utilities/emojiMask.ts +42 -0
  69. data/app/pb_kits/playbook/utilities/globalProps.ts +1 -0
  70. data/dist/chunks/_typeahead-CSCNg6cp.js +6 -0
  71. data/dist/chunks/lib-DxCgrqqG.js +29 -0
  72. data/dist/chunks/vendor.js +3 -3
  73. data/dist/menu.yml +7 -0
  74. data/dist/playbook-rails-react-bindings.js +1 -1
  75. data/dist/playbook-rails.js +1 -1
  76. data/dist/playbook.css +1 -1
  77. data/lib/playbook/forms/builder/form_field_builder.rb +15 -2
  78. data/lib/playbook/forms/builder/time_picker_field.rb +24 -0
  79. data/lib/playbook/forms/builder.rb +1 -0
  80. data/lib/playbook/version.rb +2 -2
  81. metadata +50 -4
  82. data/dist/chunks/_typeahead-X3EqK1nR.js +0 -6
  83. data/dist/chunks/lib-BHYZzndy.js +0 -29
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71dc76821c0c875363dbf67c1969a0f24eb0c5ea2fdb791b70c0d95a6732a3d5
4
- data.tar.gz: d95d190f3644e074b60828cf363fb37d99338050a29b4ab847ebe5bf99fe55f3
3
+ metadata.gz: 2d90a9283e859938c8668c9bda85da84fedc435fb8c2e944540fba1a7b834ced
4
+ data.tar.gz: 269fdbd32bd3da95347cffbcaca0e9c130e551c2288b0eb92a6f73e9d6e205fa
5
5
  SHA512:
6
- metadata.gz: 4ea3c249a93b41caa33133053b9fccb280d01b539c4bc61516e7a231174ef7cb879d35d756bb4fea03f4e5d35d7f52d39b593a49a7b0374f509a724a27fc86d3
7
- data.tar.gz: aed75a53ce523d6736b2e8ab00986472f1068f7c2f8e1f5c50194d10003ccccc1e31320a2d45fa59da4e31b6539c84a9d768112f6cf541274cac96dda9468347
6
+ metadata.gz: 8718b1da13b30dc286191523f1ba14e8ec1304389bdd0263f2b71ec6543473b20234090aca5a8d06d56716e9eed8334118429038a31f2e0983dac1c7503b1b59
7
+ data.tar.gz: 455f5232737b607d4c6c1d6b574e3bcfd5974246c2daff08bef7196af7e50e3ed3d4512fc4752240adb309ac74f1c56065e325ec7d15202ed3992da226a580a4
@@ -1,4 +1,3 @@
1
-
2
1
  @import 'pb_advanced_table/advanced_table';
3
2
  @import 'pb_avatar/avatar';
4
3
  @import 'pb_background/background';
@@ -98,6 +97,7 @@
98
97
  @import 'pb_text_input/text_input';
99
98
  @import 'pb_textarea/textarea';
100
99
  @import 'pb_time/time';
100
+ @import 'pb_time_picker/time_picker';
101
101
  @import 'pb_time_range_inline/time_range_inline';
102
102
  @import 'pb_time_stacked/time_stacked';
103
103
  @import 'pb_timeline/timeline';
@@ -30,10 +30,6 @@ module Playbook
30
30
  closeable.present? ? " remove_toast" : ""
31
31
  end
32
32
 
33
- def auto_close_class
34
- auto_close.present? ? " auto_close_#{auto_close}" : ""
35
- end
36
-
37
33
  def position_class
38
34
  horizontal && vertical ? " positioned_toast #{vertical} #{horizontal}" : ""
39
35
  end
@@ -42,6 +38,14 @@ module Playbook
42
38
  multi_line.present? ? "multi_line" : nil
43
39
  end
44
40
 
41
+ def auto_close_attribute
42
+ auto_close.present? ? { "pb-auto-close": auto_close } : {}
43
+ end
44
+
45
+ def data
46
+ Hash(prop(:data)).merge(auto_close_attribute)
47
+ end
48
+
45
49
  def icon_value
46
50
  icon || case status
47
51
  when "success"
@@ -61,9 +65,7 @@ module Playbook
61
65
 
62
66
  def classname
63
67
  default_z_index = z_index.present? ? "" : " z_index_max"
64
- # IMPORTANT: the AutoClose class must be the last class in the string for JS to read it correctly
65
- # Changing the order will break the auto_close functionality
66
- generate_classname("pb_fixed_confirmation_toast_kit", status, multi_line_class) + close_class + position_class + icon_class + default_z_index + auto_close_class
68
+ generate_classname("pb_fixed_confirmation_toast_kit", status, multi_line_class) + close_class + position_class + icon_class + default_z_index
67
69
  end
68
70
  end
69
71
  end
@@ -21,17 +21,12 @@ export default class PbFixedConfirmationToast extends PbEnhancedElement {
21
21
  }
22
22
 
23
23
  autoCloseToast(element) {
24
- const classListValues = element.classList.value
25
- const hasAutoCloseClass = classListValues.includes('auto_close')
26
-
27
- if (hasAutoCloseClass) {
28
- const classList = classListValues.split(' ')
29
- const autoCloseValue = classList[classList.length - 1].split('_')[2]
30
- const autoCloseIntValue = parseInt(autoCloseValue)
24
+ const autoCloseDataAttr = element.getAttribute('data-pb-auto-close')
31
25
 
26
+ if (autoCloseDataAttr) {
32
27
  setTimeout(() => {
33
28
  this.removeToast(element)
34
- }, autoCloseIntValue)
29
+ }, parseInt(autoCloseDataAttr))
35
30
  }
36
31
  }
37
32
  }
@@ -116,7 +116,7 @@
116
116
  <%= form.star_rating_field :example_star_rating, props: { variant: "interactive", label: true } %>
117
117
  <%= form.time_zone_select_field :example_time_zone_select, ActiveSupport::TimeZone.us_zones, { default: "Eastern Time (US & Canada)" }, props: { label: true } %>
118
118
  <%= form.multi_level_select :example_multi_level_select, props: { id: "multi-level-select-form-default", tree_data: treeData, margin_bottom: "sm", label: "Example Multi Level Select field" } %>
119
-
119
+ <%= form.time_picker :example_time_picker, props: { label: true } %>
120
120
  <%= form.actions do |action| %>
121
121
  <%= action.submit %>
122
122
  <%= action.button props: { type: "reset", text: "Cancel", variant: "secondary" } %>
@@ -117,7 +117,8 @@
117
117
  <%= form.star_rating_field :example_star_rating_validation, props: { variant: "interactive", label: true, required: true } %>
118
118
  <%= form.time_zone_select_field :example_time_zone_select, ActiveSupport::TimeZone.us_zones, { default: "Eastern Time (US & Canada)" }, props: { label: true, blank_selection: "Select a Time Zone...", required: true } %>
119
119
  <%= form.multi_level_select :example_multi_level_select, props: { id: "multi-level-select-form", tree_data: treeData, margin_bottom: "sm", required: true, label: "Example Multi Level Select field" } %>
120
-
120
+ <%= form.time_picker :example_time_picker_validation, props: { label: true, required: true, validation_message: "Please select a time." } %>
121
+
121
122
  <%= form.actions do |action| %>
122
123
  <%= action.submit %>
123
124
  <%= action.button props: { type: "reset", text: "Cancel", variant: "secondary" } %>
@@ -0,0 +1,14 @@
1
+ <%= pb_form_with(scope: :example, url: "", method: :get, validate: true) do |form| %>
2
+ <%= form.text_field :example_text_field, props: { label: true, required: true, required_indicator: true } %>
3
+ <%= form.text_field :example_text_field_2, props: { label: "Text Field Custom Label", required: true, required_indicator: true } %>
4
+ <%= form.email_field :example_email_field, props: { label: true, required: true, required_indicator: true } %>
5
+ <%= form.number_field :example_number_field, props: { label: true, required: true, required_indicator: true } %>
6
+ <%= form.search_field :example_search_field, props: { label: true, required: true, required_indicator: true } %>
7
+ <%= form.password_field :example_password_field, props: { label: true, required: true, required_indicator: true } %>
8
+ <%= form.url_field :example_url_field, props: { label: true, required: true, required_indicator: true } %>
9
+
10
+ <%= form.actions do |action| %>
11
+ <%= action.submit %>
12
+ <%= action.button props: { type: "reset", text: "Cancel", variant: "secondary" } %>
13
+ <% end %>
14
+ <% end %>
@@ -0,0 +1,3 @@
1
+ The `required_indicator` prop adds a red asterisk (*) to the input label, visually marking the field as required. This works with both `label: true` for auto-generated labels and `label: "Custom Text"` for custom labels.
2
+
3
+ While it's typically used alongside the `required` prop for HTML5 validation, you can use `required_indicator` independently if you're handling validation differently (e.g., client-side or backend validation).
@@ -4,3 +4,4 @@ examples:
4
4
  - form_form_with: Default
5
5
  - form_form_with_validate: Default + Validation
6
6
  - form_form_with_loading: Default + Loading
7
+ - form_with_required_indicator: With Optional Required Indicator
@@ -39,8 +39,8 @@
39
39
  tooltip_id: "append-to-tooltip-2",
40
40
  offset: true,
41
41
  position: "top",
42
- append_to: ".kit-show-wrapper",
42
+ append_to: ".pb--page--sideNav",
43
43
  }) do %>
44
- I'm a popover. I have been appended to the .kit-show-wrapper.
44
+ I'm a popover. I have been appended to the .pb--page--sideNav.
45
45
  <% end %>
46
46
  <% end %>
@@ -54,14 +54,15 @@ const PopoverAppendTo = (props) => {
54
54
  <Body text="Click info for more details" />
55
55
  &nbsp;
56
56
  <PbReactPopover
57
- appendTo=".kit-show-wrapper"
57
+ appendTo=".pb--page--sideNav"
58
58
  offset
59
59
  placement="top"
60
60
  reference={selectorPopoverReference}
61
61
  show={showSelectorPopover}
62
+ zIndex={10}
62
63
  {...props}
63
64
  >
64
- {'I\'m a popover. I have been appended to the .kit-show-wrapper.'}
65
+ {'I\'m a popover. I have been appended to the .pb--page--sideNav.'}
65
66
  </PbReactPopover>
66
67
  </Flex>
67
68
  </>
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, ChangeEvent } from 'react'
1
+ import React, { forwardRef, ChangeEvent, ClipboardEvent } from 'react'
2
2
  import classnames from 'classnames'
3
3
 
4
4
  import { globalProps, GlobalProps, domSafeProps } from '../utilities/globalProps'
@@ -9,8 +9,10 @@ import Card from '../pb_card/_card'
9
9
  import Caption from '../pb_caption/_caption'
10
10
  import Body from '../pb_body/_body'
11
11
  import Icon from '../pb_icon/_icon'
12
+ import colors from '../tokens/exports/_colors.module.scss'
12
13
 
13
14
  import { INPUTMASKS } from './inputMask'
15
+ import { stripEmojisForPaste, applyEmojiMask } from '../utilities/emojiMask'
14
16
 
15
17
  type TextInputProps = {
16
18
  aria?: { [key: string]: string },
@@ -18,6 +20,7 @@ type TextInputProps = {
18
20
  data?: { [key: string]: string },
19
21
  dark?: boolean,
20
22
  disabled?: boolean,
23
+ emojiMask?: boolean,
21
24
  error?: string,
22
25
  htmlOptions?: {[key: string]: string | number | boolean | (() => void)},
23
26
  id?: string,
@@ -28,6 +31,7 @@ type TextInputProps = {
28
31
  onChange: (e: React.FormEvent<HTMLInputElement>, sanitizedValue?: string) => void,
29
32
  placeholder: string,
30
33
  required?: boolean,
34
+ requiredIndicator?: boolean,
31
35
  type: string,
32
36
  value: string | number,
33
37
  children: React.ReactElement,
@@ -47,6 +51,7 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
47
51
  dark = false,
48
52
  data = {},
49
53
  disabled,
54
+ emojiMask = false,
50
55
  error,
51
56
  htmlOptions = {},
52
57
  id,
@@ -60,6 +65,7 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
60
65
  type = 'text',
61
66
  value = '',
62
67
  children = null,
68
+ requiredIndicator = false,
63
69
  autoComplete = true,
64
70
  } = props
65
71
 
@@ -99,6 +105,11 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
99
105
  const isMaskedInput = mask && mask in INPUTMASKS
100
106
 
101
107
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
108
+ // Apply emoji mask if enabled using centralized helper
109
+ if (emojiMask) {
110
+ applyEmojiMask(e.target)
111
+ }
112
+
102
113
  if (isMaskedInput) {
103
114
  const inputValue = e.target.value
104
115
 
@@ -131,6 +142,29 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
131
142
  }
132
143
  }
133
144
 
145
+ // Handle paste event for emoji mask - updates input value, cursor position, and calls onChange
146
+ const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
147
+ if (emojiMask) {
148
+ const pastedText = e.clipboardData.getData('text')
149
+ const filteredText = stripEmojisForPaste(pastedText)
150
+
151
+ if (pastedText !== filteredText) {
152
+ e.preventDefault()
153
+ const input = e.currentTarget
154
+ const start = input.selectionStart || 0
155
+ const end = input.selectionEnd || 0
156
+ const currentValue = input.value
157
+ const newValue = currentValue.slice(0, start) + filteredText + currentValue.slice(end)
158
+ const newCursorPosition = start + filteredText.length
159
+
160
+ input.value = newValue
161
+ input.selectionStart = input.selectionEnd = newCursorPosition
162
+
163
+ onChange({ ...e, target: input, currentTarget: input } as unknown as ChangeEvent<HTMLInputElement>)
164
+ }
165
+ }
166
+ }
167
+
134
168
  const childInput = children ? children.type === "input" : undefined
135
169
 
136
170
  let formattedValue;
@@ -142,10 +176,16 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
142
176
 
143
177
  const errorId = error ? `${id}-error` : undefined
144
178
 
179
+ // Set custom handler between emoji mask and input mask
180
+ const shouldUseCustomHandler = isMaskedInput || emojiMask
181
+
182
+ // Filter out emojiMask from props passed to DOM element
183
+ const { emojiMask: _emojiMask, ...domProps } = props
184
+
145
185
  const textInput = (
146
186
  childInput ? React.cloneElement(children, { className: "text_input" }) :
147
187
  (<input
148
- {...domSafeProps(props)}
188
+ {...domSafeProps(domProps)}
149
189
  aria-describedby={errorId}
150
190
  aria-invalid={!!error}
151
191
  autoComplete={typeof autoComplete === "string" ? autoComplete : ( autoComplete ? undefined : "off" )}
@@ -154,7 +194,8 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
154
194
  id={id}
155
195
  key={id}
156
196
  name={name}
157
- onChange={isMaskedInput ? handleChange : onChange}
197
+ onChange={shouldUseCustomHandler ? handleChange : onChange}
198
+ onPaste={emojiMask ? handlePaste : undefined}
158
199
  pattern={isMaskedInput ? INPUTMASKS[mask]?.pattern : undefined}
159
200
  placeholder={placeholder || (isMaskedInput ? INPUTMASKS[mask]?.placeholder : undefined)}
160
201
  ref={ref}
@@ -208,9 +249,18 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
208
249
  >
209
250
  {label && (
210
251
  <label htmlFor={id}>
211
- <Caption className="pb_text_input_kit_label"
212
- text={label}
213
- />
252
+ {
253
+ requiredIndicator ? (
254
+ <Caption className="pb_text_input_kit_label">
255
+ {label} <span style={{ color: `${colors.error}` }}>*</span>
256
+ </Caption>
257
+ ) : (
258
+ <Caption className="pb_text_input_kit_label"
259
+ text={label}
260
+ />
261
+ )
262
+ }
263
+
214
264
  </label>
215
265
  )}
216
266
  <div className={`${addOnCss} text_input_wrapper`}>
@@ -0,0 +1,7 @@
1
+ <%= pb_rails("text_input", props: {
2
+ emoji_mask: true,
3
+ label: "Emoji Mask",
4
+ placeholder: "Try typing or pasting emojis...",
5
+ }) %>
6
+
7
+
@@ -0,0 +1,24 @@
1
+ import React, { useState } from 'react'
2
+
3
+ import TextInput from '../../pb_text_input/_text_input'
4
+
5
+ const TextInputEmojiMask = (props) => {
6
+ const [basicValue, setBasicValue] = useState('')
7
+
8
+ return (
9
+ <div>
10
+ <TextInput
11
+ emojiMask
12
+ label="Emoji Mask"
13
+ onChange={({ target }) => setBasicValue(target.value)}
14
+ placeholder="Try typing or pasting emojis..."
15
+ value={basicValue}
16
+ {...props}
17
+ />
18
+ </div>
19
+ )
20
+ }
21
+
22
+ export default TextInputEmojiMask
23
+
24
+
@@ -0,0 +1,2 @@
1
+ Use the `emojiMask` / `emoji_mask` prop to prevent users from entering emoji characters (🐸 🐈 🏄‍♂️) in typed or pasted content. It allows accented characters and other non-ASCII letters (é, ü, 文).
2
+
@@ -0,0 +1,6 @@
1
+ <%= pb_rails("text_input", props: {
2
+ label: "First Name",
3
+ placeholder: "Enter first name",
4
+ id: "text_input_required_indicator",
5
+ required_indicator: true,
6
+ }) %>
@@ -0,0 +1,25 @@
1
+ import React, { useState } from 'react'
2
+
3
+ import TextInput from '../../pb_text_input/_text_input'
4
+
5
+ const TextInputDefault = (props) => {
6
+ const [firstName, setFirstName] = useState('')
7
+ const handleOnChangeFirstName = ({ target }) => {
8
+ setFirstName(target.value)
9
+ }
10
+
11
+ return (
12
+ <TextInput
13
+ id="text_input_required_indicator"
14
+ label="First Name"
15
+ name="firstName"
16
+ onChange={handleOnChangeFirstName}
17
+ placeholder="Enter first name"
18
+ requiredIndicator
19
+ value={firstName}
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ export default TextInputDefault
@@ -0,0 +1,3 @@
1
+ The `requiredIndicator`/`required_indicator` prop displays a red asterisk (*) next to the label, visually indicating that the field is required. This is purely visual and does not enforce validation.
2
+
3
+ You can use `requiredIndicator`/`required_indicator` with any validation approach: HTML5 validation via the `required` prop, client-side validation, or backend validation. For this reason, it works independently and doesn't need to be paired with the `required` prop.
@@ -10,6 +10,9 @@ examples:
10
10
  - text_input_options: Input Options
11
11
  - text_input_mask: Mask
12
12
  - text_input_autocomplete: Autocomplete
13
+ - text_input_required_indicator: Required Indicator
14
+ - text_input_emoji_mask: Emoji Mask
15
+
13
16
 
14
17
  react:
15
18
  - text_input_default: Default
@@ -22,6 +25,8 @@ examples:
22
25
  - text_input_mask: Mask
23
26
  - text_input_sanitize: Sanitized Masked Input
24
27
  - text_input_autocomplete: Autocomplete
28
+ - text_input_required_indicator: Required Indicator
29
+ - text_input_emoji_mask: Emoji Mask
25
30
 
26
31
 
27
32
  swift:
@@ -8,3 +8,5 @@ export { default as TextInputNoLabel } from './_text_input_no_label.jsx'
8
8
  export { default as TextInputMask } from './_text_input_mask.jsx'
9
9
  export { default as TextInputSanitize } from './_text_input_sanitize.jsx'
10
10
  export { default as TextInputAutocomplete } from './_text_input_autocomplete.jsx'
11
+ export { default as TextInputRequiredIndicator } from './_text_input_required_indicator.jsx'
12
+ export { default as TextInputEmojiMask } from './_text_input_emoji_mask.jsx'
@@ -1,26 +1,64 @@
1
1
  import PbEnhancedElement from "../pb_enhanced_element"
2
2
  import { INPUTMASKS } from "./inputMask"
3
+ import { stripEmojisForPaste, applyEmojiMask } from "../utilities/emojiMask"
3
4
 
4
5
  export default class PbTextInput extends PbEnhancedElement {
5
6
  static get selector() {
6
- return '[data-pb-input-mask="true"]';
7
+ return '[data-pb-input-mask="true"], [data-pb-emoji-mask="true"]';
7
8
  }
8
9
 
9
10
  connect() {
10
11
  this.handleInput = this.handleInput.bind(this);
12
+ this.handlePaste = this.handlePaste.bind(this);
11
13
  this.element.addEventListener("input", this.handleInput);
14
+ this.element.addEventListener("paste", this.handlePaste);
12
15
  this.handleInput();
13
16
  }
14
17
 
15
18
  disconnect() {
16
19
  this.element.removeEventListener("input", this.handleInput);
20
+ this.element.removeEventListener("paste", this.handlePaste);
17
21
  }
18
22
 
19
- handleInput() {
20
- const maskType = this.element.getAttribute("mask");
23
+ hasEmojiMask() {
24
+ return this.element.dataset.pbEmojiMask === "true";
25
+ }
26
+
27
+ handlePaste(event) {
28
+ if (!this.hasEmojiMask()) return;
29
+
30
+ const pastedText = event.clipboardData.getData('text');
31
+ const filteredText = stripEmojisForPaste(pastedText);
32
+
33
+ if (pastedText !== filteredText) {
34
+ event.preventDefault();
35
+ const input = this.element;
36
+ const start = input.selectionStart || 0;
37
+ const end = input.selectionEnd || 0;
38
+ const currentValue = input.value;
39
+ const newValue = currentValue.slice(0, start) + filteredText + currentValue.slice(end);
40
+ const newCursor = start + filteredText.length;
41
+
42
+ input.value = newValue;
43
+ input.selectionStart = input.selectionEnd = newCursor;
44
+
45
+ // Continue to handleInput for mask processing, emoji filtering handled above
46
+ this.handleInput({ skipEmojiFilter: true });
47
+ }
48
+ }
49
+
50
+ handleInput({ skipEmojiFilter = false } = {}) {
21
51
  const cursorPosition = this.element.selectionStart;
22
- const rawValue = this.element.value;
23
- let formattedValue = rawValue;
52
+ let baseValue = this.element.value;
53
+
54
+ // Apply emoji mask if enabled (skip if already filtered in paste handler)
55
+ if (this.hasEmojiMask() && !skipEmojiFilter) {
56
+ const result = applyEmojiMask(this.element);
57
+ baseValue = result.value;
58
+ }
59
+
60
+ const maskType = this.element.getAttribute("mask");
61
+ let formattedValue = baseValue;
24
62
 
25
63
  const maskKey = {
26
64
  currency: 'currency',
@@ -32,13 +70,14 @@ export default class PbTextInput extends PbEnhancedElement {
32
70
  }[maskType];
33
71
 
34
72
  if (maskKey && INPUTMASKS[maskKey]) {
35
- formattedValue = INPUTMASKS[maskKey].format(rawValue);
73
+ formattedValue = INPUTMASKS[maskKey].format(baseValue);
36
74
  }
37
75
 
38
76
  const sanitizedInput = this.element
39
77
  .closest(".text_input_wrapper")
40
78
  ?.querySelector('[data="sanitized-pb-input"]');
41
79
 
80
+ // Ensure sanitized input uses the already filtered value
42
81
  if (sanitizedInput) {
43
82
  switch (maskType) {
44
83
  case "ssn":
@@ -55,8 +94,10 @@ export default class PbTextInput extends PbEnhancedElement {
55
94
  }
56
95
  }
57
96
 
58
- this.element.value = formattedValue;
59
- setCursorPosition(this.element, cursorPosition, rawValue, formattedValue);
97
+ if (maskType) {
98
+ this.element.value = formattedValue;
99
+ setCursorPosition(this.element, cursorPosition, baseValue, formattedValue);
100
+ }
60
101
  }
61
102
  }
62
103
 
@@ -1,7 +1,13 @@
1
1
  <%= pb_content_tag(:div, id: nil ) do %>
2
2
  <% if object.label.present? %>
3
3
  <label for="<%= object.input_options[:id] || object.id %>" >
4
+ <% if object.required_indicator %>
5
+ <%= pb_rails("caption", props: { dark: object.dark, classname: "pb_text_input_kit_label" }) do %>
6
+ <%= object.label %><span style="color: #DA0014;"> *</span>
7
+ <% end %>
8
+ <% else %>
4
9
  <%= pb_rails("caption", props: { text: object.label, dark: object.dark, classname: "pb_text_input_kit_label" }) %>
10
+ <% end %>
5
11
  </label>
6
12
  <% end %>
7
13
  <%= content_tag(:div, class: "#{add_on_class} text_input_wrapper") do %>
@@ -18,6 +18,8 @@ module Playbook
18
18
  prop :autocomplete, default: true
19
19
  prop :disabled, type: Playbook::Props::Boolean,
20
20
  default: false
21
+ prop :emoji_mask, type: Playbook::Props::Boolean,
22
+ default: false
21
23
  prop :error
22
24
  prop :inline, type: Playbook::Props::Boolean,
23
25
  default: false
@@ -38,6 +40,8 @@ module Playbook
38
40
  prop :mask, type: Playbook::Props::Enum,
39
41
  values: ["currency", "zip_code", "postal_code", "ssn", "credit_card", "cvv", nil],
40
42
  default: nil
43
+ prop :required_indicator, type: Playbook::Props::Boolean,
44
+ default: false
41
45
 
42
46
  def classname
43
47
  default_margin_bottom = margin_bottom.present? ? "" : " mb_sm"
@@ -115,7 +119,9 @@ module Playbook
115
119
  def validation_data
116
120
  fields = input_options.dig(:data) || {}
117
121
  fields[:message] = validation_message unless validation_message.blank?
118
- mask ? fields.merge(pb_input_mask: true) : fields
122
+ fields[:pb_input_mask] = true if mask
123
+ fields[:pb_emoji_mask] = true if emoji_mask
124
+ fields
119
125
  end
120
126
 
121
127
  def error_class
@@ -344,3 +344,72 @@ test('does not add autocomplete attribute otherwise', () => {
344
344
  const input = within(kit).getByRole('textbox')
345
345
  expect(input).not.toHaveAttribute("autocomplete")
346
346
  })
347
+
348
+ test('renders required indicator asterisk when requiredIndicator is true', () => {
349
+ render(
350
+ <TextInput
351
+ data={{ testid: testId }}
352
+ label="Email Address"
353
+ requiredIndicator
354
+ />
355
+ )
356
+
357
+ const kit = screen.getByTestId(testId)
358
+ const label = within(kit).getByText(/Email Address/)
359
+
360
+ expect(label).toBeInTheDocument()
361
+ expect(kit).toHaveTextContent('*')
362
+ })
363
+
364
+ const TextInputEmojiMask = (props) => {
365
+ const [value, setValue] = useState('')
366
+ const handleOnChange = ({ target }) => {
367
+ setValue(target.value)
368
+ }
369
+
370
+ return (
371
+ <TextInput
372
+ emojiMask
373
+ onChange={handleOnChange}
374
+ value={value}
375
+ {...props}
376
+ />
377
+ )
378
+ }
379
+
380
+ test('removes emoji characters when emojiMask is enabled', () => {
381
+ render(
382
+ <TextInputEmojiMask
383
+ data={{ testid: testId }}
384
+ />
385
+ )
386
+
387
+ const kit = screen.getByTestId(testId)
388
+ const input = within(kit).getByRole('textbox')
389
+
390
+ fireEvent.change(input, { target: { value: 'Hello 👋 World 🌍' } })
391
+ expect(input.value).toBe('Hello World ')
392
+
393
+ fireEvent.change(input, { target: { value: '😀😂🎉' } })
394
+ expect(input.value).toBe('')
395
+
396
+ fireEvent.change(input, { target: { value: 'Hello World' } })
397
+ expect(input.value).toBe('Hello World')
398
+ })
399
+
400
+ test('allows accented characters when emojiMask is enabled', () => {
401
+ render(
402
+ <TextInputEmojiMask
403
+ data={{ testid: testId }}
404
+ />
405
+ )
406
+
407
+ const kit = screen.getByTestId(testId)
408
+ const input = within(kit).getByRole('textbox')
409
+
410
+ fireEvent.change(input, { target: { value: 'Café résumé naïve' } })
411
+ expect(input.value).toBe('Café résumé naïve')
412
+
413
+ fireEvent.change(input, { target: { value: 'àëǒüñ' } })
414
+ expect(input.value).toBe('àëǒüñ')
415
+ })