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.
- 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_fixed_confirmation_toast/fixed_confirmation_toast.rb +9 -7
- data/app/pb_kits/playbook/pb_fixed_confirmation_toast/index.js +3 -8
- data/app/pb_kits/playbook/pb_multi_level_select/_multi_level_select.tsx +7 -0
- data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_disabled_options.md +1 -1
- data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_single_disabled.html.erb +135 -0
- data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_single_disabled.jsx +147 -0
- data/app/pb_kits/playbook/pb_multi_level_select/docs/_multi_level_select_single_disabled.md +1 -0
- data/app/pb_kits/playbook/pb_multi_level_select/docs/example.yml +2 -0
- data/app/pb_kits/playbook/pb_multi_level_select/docs/index.js +1 -0
- data/app/pb_kits/playbook/pb_multi_level_select/multi_level_select.test.jsx +402 -27
- data/app/pb_kits/playbook/pb_multi_level_select/multi_level_select_options.tsx +1 -0
- data/app/pb_kits/playbook/pb_radio/_radio.scss +8 -0
- 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-DQWz6v7R.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/dist/playbook.css +1 -1
- data/lib/playbook/forms/builder/form_field_builder.rb +2 -0
- data/lib/playbook/version.rb +1 -1
- metadata +14 -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,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(
|
|
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={
|
|
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,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
|
+
|
|
@@ -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
|
-
|
|
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
|
+
})
|