playbook_ui 15.7.0.pre.alpha.PLAY2675dropdownquickpickcustomquickpickdates13330 → 15.7.0.pre.alpha.PLAY2704multilevelselectsingledisabledoptions13404

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +1 -13
  3. data/app/pb_kits/playbook/pb_dropdown/docs/example.yml +0 -2
  4. data/app/pb_kits/playbook/pb_dropdown/docs/index.js +1 -2
  5. data/app/pb_kits/playbook/pb_dropdown/dropdown.rb +1 -6
  6. data/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx +0 -121
  7. data/app/pb_kits/playbook/pb_dropdown/quickpick/index.ts +9 -85
  8. data/app/pb_kits/playbook/pb_dropdown/quickpick_helper.rb +2 -83
  9. data/app/pb_kits/playbook/pb_fixed_confirmation_toast/fixed_confirmation_toast.rb +9 -7
  10. data/app/pb_kits/playbook/pb_fixed_confirmation_toast/index.js +3 -8
  11. data/app/pb_kits/playbook/pb_multi_level_select/_multi_level_select.tsx +7 -0
  12. data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_disabled_options.md +1 -1
  13. data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_single_disabled.html.erb +135 -0
  14. data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_single_disabled.jsx +147 -0
  15. data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_single_disabled.md +1 -0
  16. data/app/pb_kits/playbook/pb_multi_level_select/docs/example.yml +2 -0
  17. data/app/pb_kits/playbook/pb_multi_level_select/docs/index.js +1 -0
  18. data/app/pb_kits/playbook/pb_multi_level_select/multi_level_select.test.jsx +402 -27
  19. data/app/pb_kits/playbook/pb_multi_level_select/multi_level_select_options.tsx +1 -0
  20. data/app/pb_kits/playbook/pb_radio/_radio.scss +8 -0
  21. data/app/pb_kits/playbook/pb_text_input/_text_input.tsx +41 -3
  22. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.html.erb +7 -0
  23. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.jsx +24 -0
  24. data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.md +2 -0
  25. data/app/pb_kits/playbook/pb_text_input/docs/example.yml +2 -0
  26. data/app/pb_kits/playbook/pb_text_input/docs/index.js +1 -0
  27. data/app/pb_kits/playbook/pb_text_input/index.js +49 -8
  28. data/app/pb_kits/playbook/pb_text_input/text_input.rb +5 -1
  29. data/app/pb_kits/playbook/pb_text_input/text_input.test.js +53 -0
  30. data/app/pb_kits/playbook/pb_textarea/_textarea.tsx +38 -2
  31. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.html.erb +5 -0
  32. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.jsx +24 -0
  33. data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.md +1 -0
  34. data/app/pb_kits/playbook/pb_textarea/docs/example.yml +2 -0
  35. data/app/pb_kits/playbook/pb_textarea/docs/index.js +1 -0
  36. data/app/pb_kits/playbook/pb_textarea/index.ts +62 -5
  37. data/app/pb_kits/playbook/pb_textarea/textarea.html.erb +1 -0
  38. data/app/pb_kits/playbook/pb_textarea/textarea.rb +8 -0
  39. data/app/pb_kits/playbook/pb_textarea/textarea.test.js +57 -2
  40. data/app/pb_kits/playbook/utilities/emojiMask.ts +42 -0
  41. data/dist/chunks/{_typeahead-Ckz1ce-2.js → _typeahead-DQWz6v7R.js} +2 -2
  42. data/dist/chunks/{lib-DxDBrGZX.js → lib-DxCgrqqG.js} +1 -1
  43. data/dist/chunks/vendor.js +3 -3
  44. data/dist/playbook-rails-react-bindings.js +1 -1
  45. data/dist/playbook-rails.js +1 -1
  46. data/dist/playbook.css +1 -1
  47. data/lib/playbook/forms/builder/form_field_builder.rb +2 -0
  48. data/lib/playbook/version.rb +1 -1
  49. metadata +14 -8
  50. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom.jsx +0 -56
  51. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom.md +0 -10
  52. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom_rails.html.erb +0 -64
  53. data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom_rails.md +0 -10
@@ -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'
@@ -12,6 +12,7 @@ import Icon from '../pb_icon/_icon'
12
12
  import colors from '../tokens/exports/_colors.module.scss'
13
13
 
14
14
  import { INPUTMASKS } from './inputMask'
15
+ import { stripEmojisForPaste, applyEmojiMask } from '../utilities/emojiMask'
15
16
 
16
17
  type TextInputProps = {
17
18
  aria?: { [key: string]: string },
@@ -19,6 +20,7 @@ type TextInputProps = {
19
20
  data?: { [key: string]: string },
20
21
  dark?: boolean,
21
22
  disabled?: boolean,
23
+ emojiMask?: boolean,
22
24
  error?: string,
23
25
  htmlOptions?: {[key: string]: string | number | boolean | (() => void)},
24
26
  id?: string,
@@ -49,6 +51,7 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
49
51
  dark = false,
50
52
  data = {},
51
53
  disabled,
54
+ emojiMask = false,
52
55
  error,
53
56
  htmlOptions = {},
54
57
  id,
@@ -102,6 +105,11 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
102
105
  const isMaskedInput = mask && mask in INPUTMASKS
103
106
 
104
107
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
108
+ // Apply emoji mask if enabled using centralized helper
109
+ if (emojiMask) {
110
+ applyEmojiMask(e.target)
111
+ }
112
+
105
113
  if (isMaskedInput) {
106
114
  const inputValue = e.target.value
107
115
 
@@ -134,6 +142,29 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
134
142
  }
135
143
  }
136
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
+
137
168
  const childInput = children ? children.type === "input" : undefined
138
169
 
139
170
  let formattedValue;
@@ -145,10 +176,16 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
145
176
 
146
177
  const errorId = error ? `${id}-error` : undefined
147
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
+
148
185
  const textInput = (
149
186
  childInput ? React.cloneElement(children, { className: "text_input" }) :
150
187
  (<input
151
- {...domSafeProps(props)}
188
+ {...domSafeProps(domProps)}
152
189
  aria-describedby={errorId}
153
190
  aria-invalid={!!error}
154
191
  autoComplete={typeof autoComplete === "string" ? autoComplete : ( autoComplete ? undefined : "off" )}
@@ -157,7 +194,8 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
157
194
  id={id}
158
195
  key={id}
159
196
  name={name}
160
- onChange={isMaskedInput ? handleChange : onChange}
197
+ onChange={shouldUseCustomHandler ? handleChange : onChange}
198
+ onPaste={emojiMask ? handlePaste : undefined}
161
199
  pattern={isMaskedInput ? INPUTMASKS[mask]?.pattern : undefined}
162
200
  placeholder={placeholder || (isMaskedInput ? INPUTMASKS[mask]?.placeholder : undefined)}
163
201
  ref={ref}
@@ -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
+
@@ -11,6 +11,7 @@ examples:
11
11
  - text_input_mask: Mask
12
12
  - text_input_autocomplete: Autocomplete
13
13
  - text_input_required_indicator: Required Indicator
14
+ - text_input_emoji_mask: Emoji Mask
14
15
 
15
16
 
16
17
  react:
@@ -25,6 +26,7 @@ examples:
25
26
  - text_input_sanitize: Sanitized Masked Input
26
27
  - text_input_autocomplete: Autocomplete
27
28
  - text_input_required_indicator: Required Indicator
29
+ - text_input_emoji_mask: Emoji Mask
28
30
 
29
31
 
30
32
  swift:
@@ -9,3 +9,4 @@ 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
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
 
@@ -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
@@ -117,7 +119,9 @@ module Playbook
117
119
  def validation_data
118
120
  fields = input_options.dig(:data) || {}
119
121
  fields[:message] = validation_message unless validation_message.blank?
120
- 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
121
125
  end
122
126
 
123
127
  def error_class
@@ -360,3 +360,56 @@ test('renders required indicator asterisk when requiredIndicator is true', () =>
360
360
  expect(label).toBeInTheDocument()
361
361
  expect(kit).toHaveTextContent('*')
362
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
+ })
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable react-hooks/rules-of-hooks */
2
2
 
3
- import React, { forwardRef, useEffect, useRef } from 'react'
3
+ import React, { forwardRef, useEffect, useRef, ChangeEvent, ClipboardEvent } from 'react'
4
4
  import classnames from 'classnames'
5
5
 
6
6
  import PbTextarea from '.'
@@ -14,6 +14,8 @@ import Caption from '../pb_caption/_caption'
14
14
  import Flex from '../pb_flex/_flex'
15
15
  import FlexItem from '../pb_flex/_flex_item'
16
16
 
17
+ import { stripEmojisForPaste, applyEmojiMask } from '../utilities/emojiMask'
18
+
17
19
  type TextareaProps = {
18
20
  aria?: {[key: string]: string},
19
21
  characterCount?: string,
@@ -21,6 +23,7 @@ type TextareaProps = {
21
23
  children?: React.ReactChild[],
22
24
  data?: {[key: string]: string},
23
25
  disabled?: boolean,
26
+ emojiMask?: boolean,
24
27
  error?: string,
25
28
  htmlOptions?: {[key: string]: string | number | boolean | (() => void)},
26
29
  id?: string,
@@ -45,6 +48,7 @@ const Textarea = ({
45
48
  children,
46
49
  data = {},
47
50
  disabled,
51
+ emojiMask = false,
48
52
  htmlOptions = {},
49
53
  inline = false,
50
54
  resize = 'none',
@@ -67,6 +71,37 @@ const Textarea = ({
67
71
  }
68
72
  })
69
73
 
74
+ const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
75
+ // Apply emoji mask if enabled using centralized helper
76
+ if (emojiMask) {
77
+ applyEmojiMask(e.target)
78
+ }
79
+ onChange(e)
80
+ }
81
+
82
+ // Handle paste event for emoji mask - updates textarea value, cursor position, and calls onChange
83
+ const handlePaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
84
+ if (emojiMask) {
85
+ const pastedText = e.clipboardData.getData('text')
86
+ const filteredText = stripEmojisForPaste(pastedText)
87
+
88
+ if (pastedText !== filteredText) {
89
+ e.preventDefault()
90
+ const textarea = e.currentTarget
91
+ const start = textarea.selectionStart || 0
92
+ const end = textarea.selectionEnd || 0
93
+ const currentValue = textarea.value
94
+ const newValue = currentValue.slice(0, start) + filteredText + currentValue.slice(end)
95
+ const newCursorPosition = start + filteredText.length
96
+
97
+ textarea.value = newValue
98
+ textarea.selectionStart = textarea.selectionEnd = newCursorPosition
99
+
100
+ onChange({ ...e, target: textarea, currentTarget: textarea } as unknown as ChangeEvent<HTMLTextAreaElement>)
101
+ }
102
+ }
103
+ }
104
+
70
105
  const errorClass = error ? 'error' : null
71
106
  const inlineClass = inline ? 'inline' : ''
72
107
  const resizeClass = `resize_${resize}`
@@ -94,7 +129,8 @@ const Textarea = ({
94
129
  <textarea
95
130
  disabled={disabled}
96
131
  name={name}
97
- onChange={onChange}
132
+ onChange={emojiMask ? handleChange : onChange}
133
+ onPaste={emojiMask ? handlePaste : undefined}
98
134
  placeholder={placeholder}
99
135
  ref={ref}
100
136
  required={required}
@@ -0,0 +1,5 @@
1
+ <%= pb_rails("textarea", props: {
2
+ emoji_mask: true,
3
+ label: "Emoji Mask",
4
+ placeholder: "Try typing or pasting emojis...",
5
+ }) %>
@@ -0,0 +1,24 @@
1
+ import React, { useState } from 'react'
2
+
3
+ import Textarea from '../../pb_textarea/_textarea'
4
+
5
+ const TextareaEmojiMask = (props) => {
6
+ const [basicValue, setBasicValue] = useState('')
7
+
8
+ return (
9
+ <div>
10
+ <Textarea
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 TextareaEmojiMask
23
+
24
+
@@ -0,0 +1 @@
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 (é, ü, 文).
@@ -7,6 +7,7 @@ examples:
7
7
  - textarea_error: Textarea w/ Error
8
8
  - textarea_character_counter: Character Counter
9
9
  - textarea_inline: Inline
10
+ - textarea_emoji_mask: Emoji Mask
10
11
 
11
12
  react:
12
13
  - textarea_default: Default
@@ -15,6 +16,7 @@ examples:
15
16
  - textarea_error: Textarea w/ Error
16
17
  - textarea_character_counter: Character Counter
17
18
  - textarea_inline: Inline
19
+ - textarea_emoji_mask: Emoji Mask
18
20
 
19
21
  swift:
20
22
  - textarea_default_swift: Default
@@ -4,3 +4,4 @@ export { default as TextareaCustom } from './_textarea_custom.jsx'
4
4
  export { default as TextareaError } from './_textarea_error.jsx'
5
5
  export { default as TextareaCharacterCounter } from './_textarea_character_counter.jsx'
6
6
  export { default as TextareaInline } from './_textarea_inline.jsx'
7
+ export { default as TextareaEmojiMask } from './_textarea_emoji_mask.jsx'
@@ -1,19 +1,76 @@
1
1
  import PbEnhancedElement from '../pb_enhanced_element'
2
+ import { stripEmojisForPaste, applyEmojiMask } from '../utilities/emojiMask'
2
3
 
3
4
  export default class PbTextarea extends PbEnhancedElement {
4
5
  style: {[key: string]: string}
5
6
  scrollHeight: string
7
+ private skipNextEmojiFilter = false
8
+
6
9
  static get selector(): string {
7
- return '.resize_auto textarea'
10
+ return '.resize_auto textarea, [data-pb-emoji-mask="true"]'
11
+ }
12
+
13
+ hasEmojiMask(): boolean {
14
+ return (this.element as HTMLElement).dataset.pbEmojiMask === "true"
8
15
  }
9
16
 
10
17
  onInput(): void {
11
- this.style.height = 'auto'
12
- this.style.height = (this.scrollHeight) + 'px'
18
+ if ((this.element as HTMLElement).closest('.resize_auto')) {
19
+ this.style.height = 'auto'
20
+ this.style.height = (this.scrollHeight) + 'px'
21
+ }
22
+ }
23
+
24
+ handleEmojiInput = (): void => {
25
+ if (!this.hasEmojiMask()) return
26
+
27
+ if (this.skipNextEmojiFilter) {
28
+ this.skipNextEmojiFilter = false
29
+ return
30
+ }
31
+
32
+ applyEmojiMask(this.element as HTMLTextAreaElement)
33
+ }
34
+
35
+ handleEmojiPaste = (event: ClipboardEvent): void => {
36
+ if (!this.hasEmojiMask()) return
37
+
38
+ const pastedText = event.clipboardData?.getData('text') || ''
39
+ const filteredText = stripEmojisForPaste(pastedText)
40
+
41
+ if (pastedText !== filteredText) {
42
+ event.preventDefault()
43
+ const textarea = this.element as HTMLTextAreaElement
44
+ const start = textarea.selectionStart || 0
45
+ const end = textarea.selectionEnd || 0
46
+ const currentValue = textarea.value
47
+ const newValue = currentValue.slice(0, start) + filteredText + currentValue.slice(end)
48
+ const newCursor = start + filteredText.length
49
+
50
+ textarea.value = newValue
51
+ textarea.selectionStart = textarea.selectionEnd = newCursor
52
+
53
+ this.skipNextEmojiFilter = true
54
+
55
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
56
+ }
13
57
  }
14
58
 
15
59
  connect(): void {
16
- this.element.setAttribute('style', 'height:' + (this.element.scrollHeight) + 'px;overflow-y:hidden;')
17
- this.element.addEventListener('input', this.onInput, false)
60
+ if ((this.element as HTMLElement).closest('.resize_auto')) {
61
+ this.element.setAttribute('style', 'height:' + (this.element as HTMLTextAreaElement).scrollHeight + 'px;overflow-y:hidden;')
62
+ this.element.addEventListener('input', this.onInput, false)
63
+ }
64
+
65
+ if (this.hasEmojiMask()) {
66
+ this.element.addEventListener('input', this.handleEmojiInput, false)
67
+ this.element.addEventListener('paste', this.handleEmojiPaste as EventListener, false)
68
+ }
69
+ }
70
+
71
+ disconnect(): void {
72
+ this.element.removeEventListener('input', this.onInput, false)
73
+ this.element.removeEventListener('input', this.handleEmojiInput, false)
74
+ this.element.removeEventListener('paste', this.handleEmojiPaste as EventListener, false)
18
75
  }
19
76
  }
@@ -11,6 +11,7 @@
11
11
  <%= text_area(
12
12
  :object,
13
13
  :method,
14
+ :data => object.textarea_options[:data],
14
15
  :max_characters => object.max_characters,
15
16
  :name => object.name,
16
17
  :onkeyup => object.onkeyup,
@@ -3,6 +3,8 @@
3
3
  module Playbook
4
4
  module PbTextarea
5
5
  class Textarea < Playbook::KitBase
6
+ prop :emoji_mask, type: Playbook::Props::Boolean,
7
+ default: false
6
8
  prop :error
7
9
  prop :inline, type: Playbook::Props::Boolean,
8
10
  default: false
@@ -28,6 +30,12 @@ module Playbook
28
30
  max_characters && character_count ? "#{character_count} / #{max_characters}" : character_count
29
31
  end
30
32
 
33
+ def textarea_options
34
+ {
35
+ data: emoji_mask ? { pb_emoji_mask: true } : {},
36
+ }
37
+ end
38
+
31
39
  private
32
40
 
33
41
  def error_class
@@ -1,5 +1,5 @@
1
- import React from "react"
2
- import { render, screen } from "../utilities/test-utils"
1
+ import React, { useState } from "react"
2
+ import { render, screen, fireEvent } from "../utilities/test-utils"
3
3
 
4
4
  import Textarea from "./_textarea"
5
5
 
@@ -211,3 +211,58 @@ describe("TextArea Kit Props", () => {
211
211
  expect(textarea.required).toBeTruthy()
212
212
  })
213
213
  })
214
+
215
+ describe("Textarea Emoji Mask", () => {
216
+ const TextareaEmojiMask = (props) => {
217
+ const [value, setValue] = useState('')
218
+ const handleOnChange = ({ target }) => {
219
+ setValue(target.value)
220
+ }
221
+
222
+ return (
223
+ <Textarea
224
+ emojiMask
225
+ onChange={handleOnChange}
226
+ value={value}
227
+ {...props}
228
+ />
229
+ )
230
+ }
231
+
232
+ test("removes emoji characters when emojiMask is enabled", () => {
233
+ render(
234
+ <TextareaEmojiMask
235
+ data={{ testid: testId }}
236
+ />
237
+ )
238
+
239
+ const kit = screen.getByTestId(testId)
240
+ const textarea = kit.querySelector("textarea")
241
+
242
+ fireEvent.change(textarea, { target: { value: 'Hello 👋 World 🌍' } })
243
+ expect(textarea.value).toBe('Hello World ')
244
+
245
+ fireEvent.change(textarea, { target: { value: '😀😂🎉' } })
246
+ expect(textarea.value).toBe('')
247
+
248
+ fireEvent.change(textarea, { target: { value: 'Hello World' } })
249
+ expect(textarea.value).toBe('Hello World')
250
+ })
251
+
252
+ test("allows accented characters when emojiMask is enabled", () => {
253
+ render(
254
+ <TextareaEmojiMask
255
+ data={{ testid: testId }}
256
+ />
257
+ )
258
+
259
+ const kit = screen.getByTestId(testId)
260
+ const textarea = kit.querySelector("textarea")
261
+
262
+ fireEvent.change(textarea, { target: { value: 'Café résumé naïve' } })
263
+ expect(textarea.value).toBe('Café résumé naïve')
264
+
265
+ fireEvent.change(textarea, { target: { value: 'àëǒüñ' } })
266
+ expect(textarea.value).toBe('àëǒüñ')
267
+ })
268
+ })