playbook_ui 15.7.0.pre.alpha.PLAY2675dropdownquickpickcustomquickpickdates13330 → 15.7.0.pre.alpha.PLAY2678emojimask13284
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/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +1 -13
- data/app/pb_kits/playbook/pb_dropdown/docs/example.yml +0 -2
- data/app/pb_kits/playbook/pb_dropdown/docs/index.js +1 -2
- data/app/pb_kits/playbook/pb_dropdown/dropdown.rb +1 -6
- data/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx +0 -121
- data/app/pb_kits/playbook/pb_dropdown/quickpick/index.ts +9 -85
- data/app/pb_kits/playbook/pb_dropdown/quickpick_helper.rb +2 -83
- data/app/pb_kits/playbook/pb_form/docs/_form_form_with.html.erb +2 -2
- data/app/pb_kits/playbook/pb_text_input/_text_input.tsx +41 -3
- data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.html.erb +7 -0
- data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.jsx +24 -0
- data/app/pb_kits/playbook/pb_text_input/docs/_text_input_emoji_mask.md +2 -0
- data/app/pb_kits/playbook/pb_text_input/docs/example.yml +2 -0
- data/app/pb_kits/playbook/pb_text_input/docs/index.js +1 -0
- data/app/pb_kits/playbook/pb_text_input/index.js +49 -8
- data/app/pb_kits/playbook/pb_text_input/text_input.rb +5 -1
- data/app/pb_kits/playbook/pb_text_input/text_input.test.js +53 -0
- data/app/pb_kits/playbook/pb_textarea/_textarea.tsx +38 -2
- data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.html.erb +5 -0
- data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.jsx +24 -0
- data/app/pb_kits/playbook/pb_textarea/docs/_textarea_emoji_mask.md +1 -0
- data/app/pb_kits/playbook/pb_textarea/docs/example.yml +2 -0
- data/app/pb_kits/playbook/pb_textarea/docs/index.js +1 -0
- data/app/pb_kits/playbook/pb_textarea/index.ts +62 -5
- data/app/pb_kits/playbook/pb_textarea/textarea.html.erb +1 -0
- data/app/pb_kits/playbook/pb_textarea/textarea.rb +8 -0
- data/app/pb_kits/playbook/pb_textarea/textarea.test.js +57 -2
- data/app/pb_kits/playbook/utilities/emojiMask.ts +42 -0
- data/dist/chunks/{_typeahead-Ckz1ce-2.js → _typeahead-CSCNg6cp.js} +2 -2
- data/dist/chunks/{lib-DxDBrGZX.js → lib-DxCgrqqG.js} +1 -1
- data/dist/chunks/vendor.js +3 -3
- data/dist/playbook-rails-react-bindings.js +1 -1
- data/dist/playbook-rails.js +1 -1
- data/lib/playbook/forms/builder/form_field_builder.rb +2 -0
- data/lib/playbook/version.rb +1 -1
- metadata +11 -8
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom.jsx +0 -56
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom.md +0 -10
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom_rails.html.erb +0 -64
- data/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_quickpick_custom_rails.md +0 -10
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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(
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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,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.
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Regex to match emoji/pictographic characters
|
|
2
|
+
// With modifiers: Zero Width Joiner, Variation Selectors, Skin Tone Modifiers
|
|
3
|
+
export const EMOJI_REGEX = /\p{Extended_Pictographic}|\u200D|\uFE0F|[\u{1F3FB}-\u{1F3FF}]/gu
|
|
4
|
+
|
|
5
|
+
// Utility function to strip emojis from text when typing emojis
|
|
6
|
+
export const stripEmojisForTyping = (text: string): string => {
|
|
7
|
+
return text.replace(EMOJI_REGEX, '')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Utility function to strip emojis and clean up whitespace when pasting emojis
|
|
11
|
+
export const stripEmojisForPaste = (text: string): string => {
|
|
12
|
+
return stripEmojisForTyping(text)
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type EmojiMaskResult = {
|
|
18
|
+
value: string
|
|
19
|
+
cursor: number | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Union type for elements that support emoji masking
|
|
23
|
+
type TextInputElement = HTMLInputElement | HTMLTextAreaElement
|
|
24
|
+
|
|
25
|
+
export const applyEmojiMask = (
|
|
26
|
+
element: TextInputElement
|
|
27
|
+
): EmojiMaskResult => {
|
|
28
|
+
const cursor = element.selectionStart
|
|
29
|
+
const original = element.value
|
|
30
|
+
const filtered = stripEmojisForTyping(original)
|
|
31
|
+
|
|
32
|
+
if (original !== filtered) {
|
|
33
|
+
const beforeCursor = original.slice(0, cursor || 0)
|
|
34
|
+
const newCursor = stripEmojisForTyping(beforeCursor).length
|
|
35
|
+
element.value = filtered
|
|
36
|
+
element.selectionStart = element.selectionEnd = newCursor
|
|
37
|
+
return { value: filtered, cursor: newCursor }
|
|
38
|
+
}
|
|
39
|
+
return { value: original, cursor }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|