Skip to content

Commit 6a6cb62

Browse files
juliacanzaniclaude
andcommitted
Refactor Text and Textarea fields with three-path rendering
Text field now routes between dynamic (ProseMirror), masked (input mask hook), and plain (TUI TextInput) paths. Textarea gains the same dynamic routing for multi-line mode. Uses TUI Field compound component for consistent ARIA wiring. Adds Text.stories.tsx covering all three code paths. Includes build output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ab9713 commit 6a6cb62

10 files changed

Lines changed: 324 additions & 88 deletions

File tree

assets/build/beaver-builder/index.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/build/default/index.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/build/elementor/index.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/build/example.min.js

Lines changed: 23 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/build/index.min.js

Lines changed: 23 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/build/wp/index.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useState } from 'react'
2+
import type { Meta, StoryObj } from '@storybook/react-vite'
3+
4+
import Text from './Text'
5+
6+
const meta = {
7+
title: 'Field/Text',
8+
component: Text,
9+
parameters: {
10+
layout: 'centered'
11+
},
12+
tags: ['autodocs'],
13+
argTypes: {
14+
label: { control: 'text' },
15+
description: { control: 'text' },
16+
placeholder: { control: 'text' },
17+
readOnly: { control: 'boolean' },
18+
inputMask: { control: 'text' },
19+
prefix: { control: 'text' },
20+
suffix: { control: 'text' }
21+
},
22+
args: {
23+
label: 'Label',
24+
description: 'Helper text',
25+
placeholder: 'Type here'
26+
}
27+
} satisfies Meta<typeof Text>
28+
29+
export default meta
30+
31+
type Story = StoryObj<typeof meta>
32+
33+
export const SimpleTuiInput: Story = {
34+
args: {
35+
label: 'Simple Text',
36+
description: 'This path uses TUI TextInput',
37+
value: 'Hello world'
38+
}
39+
}
40+
41+
export const MaskedEnhancedInput: Story = {
42+
args: {
43+
label: 'Masked Text',
44+
description: 'This path uses legacy dynamic text input (CodeMirror)',
45+
inputMask: '9999-99-99',
46+
value: '2026-02-24'
47+
}
48+
}
49+
50+
export const PrefixSuffixEnhancedInput: Story = {
51+
args: {
52+
label: 'Prefixed Text',
53+
description: 'Prefix/suffix also routes through enhanced input mode',
54+
prefix: '$',
55+
suffix: ' USD',
56+
value: '19.99'
57+
}
58+
}
59+
60+
export const InteractiveComparison: Story = {
61+
render: () => {
62+
const [simpleValue, setSimpleValue] = useState('Simple value')
63+
const [enhancedValue, setEnhancedValue] = useState('2026-02-24')
64+
65+
return (
66+
<div style={{ display: 'grid', gap: '1rem', minWidth: '360px' }}>
67+
<Text
68+
label="Simple (TUI)"
69+
description="No dynamic features"
70+
value={simpleValue}
71+
onChange={setSimpleValue}
72+
placeholder="Plain text"
73+
/>
74+
<Text
75+
label="Enhanced (Mask)"
76+
description="Masked/dynamic features enabled"
77+
value={enhancedValue}
78+
onChange={setEnhancedValue}
79+
inputMask="9999-99-99"
80+
placeholder="YYYY-MM-DD"
81+
/>
82+
</div>
83+
)
84+
}
85+
}
86+
Lines changed: 126 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,151 @@
1-
import {
1+
import {
22
useRef,
33
useEffect,
4-
useState
4+
useState,
5+
lazy,
6+
Suspense
57
} from 'react'
68

79
import { useTextField } from 'react-aria'
8-
import { TextInput } from '../../dynamic/'
10+
import { Field, TextInput as TuiTextInput } from '@tangible/ui'
911

10-
import {
11-
Description,
12-
Label
13-
} from '../../base'
12+
import Description from '../../base/field/Description'
13+
import Label from '../../base/field/Label'
14+
import { useInputMask, matchesMask } from '../../../utils/use-input-mask'
15+
16+
const DynamicEditor = lazy(() => import('../../dynamic/editor/DynamicEditor'))
1417

1518
/**
19+
* Text field with three routing paths:
20+
* 1. dynamic → ProseMirror-based DynamicEditor (lazy-loaded)
21+
* 2. inputMask (no dynamic) → TUI TextInput + useInputMask hook
22+
* 3. plain → TUI TextInput with optional prefix/suffix
23+
*
1624
* @see https://react-spectrum.adobe.com/react-aria/useTextField.html
1725
*/
1826

1927
const TextField = props => {
28+
const {
29+
dynamic,
30+
inputMask,
31+
prefix,
32+
suffix,
33+
} = props
34+
35+
const hasDynamic = Boolean(dynamic)
2036

2137
const [value, setValue] = useState(props.value ?? '')
2238
const ref = useRef()
39+
const inputMaskRef = useRef<HTMLInputElement | null>(null)
2340

24-
const {
41+
const {
2542
labelProps,
26-
inputProps,
27-
descriptionProps,
43+
inputProps,
44+
descriptionProps,
2845
} = useTextField(props, ref)
29-
46+
47+
useInputMask(inputMaskRef, !hasDynamic ? inputMask : null)
48+
3049
useEffect(() => {
31-
if( props.onChange ) props.onChange(value)
50+
if (props.onChange) props.onChange(value)
3251
}, [value])
33-
34-
return(
35-
<div className='tf-text'>
36-
{ props.label &&
37-
<Label labelProps={ labelProps } parent={ props }>
38-
{ props.label }
39-
</Label> }
40-
<TextInput
41-
{ ...props }
42-
inputProps={ inputProps }
43-
onChange={ setValue }
44-
ref={ ref }
45-
dynamic={ props.dynamic ?? false }
46-
/>
47-
{ props.description &&
48-
<Description descriptionProps={ descriptionProps } parent={ props }>
49-
{ props.description }
50-
</Description> }
52+
53+
// Validate initial mask value
54+
const getInitialMaskValue = () => {
55+
if (!inputMask || !value) return value
56+
const stripped = stripAffixes(value, prefix, suffix)
57+
return matchesMask(stripped, inputMask) ? value : ''
58+
}
59+
60+
/**
61+
* Dynamic path — ProseMirror editor
62+
*/
63+
if (hasDynamic) {
64+
return (
65+
<div className="tf-text tf-text-dynamic">
66+
{props.label &&
67+
<Label labelProps={labelProps} parent={props}>
68+
{props.label}
69+
</Label>}
70+
<Suspense fallback={null}>
71+
<DynamicEditor
72+
value={value}
73+
onChange={setValue}
74+
dynamic={dynamic}
75+
name={props.name}
76+
placeholder={props.placeholder}
77+
readOnly={props.readOnly}
78+
prefix={prefix}
79+
suffix={suffix}
80+
singleLine={true}
81+
inputProps={inputProps}
82+
/>
83+
</Suspense>
84+
{props.description &&
85+
<Description descriptionProps={descriptionProps} parent={props}>
86+
{props.description}
87+
</Description>}
88+
</div>
89+
)
90+
}
91+
92+
/**
93+
* Native path — TUI TextInput (with optional input mask, prefix, suffix)
94+
*/
95+
return (
96+
<div className="tf-text tf-text-tui">
97+
<Field
98+
className={props.className}
99+
required={Boolean(props.isRequired)}
100+
disabled={Boolean(props.isDisabled || props.readOnly)}
101+
error={Boolean(props.isInvalid || props.error)}
102+
>
103+
{props.label &&
104+
<Field.Label hidden={Boolean(props.labelVisuallyHidden)}>
105+
{props.label}
106+
</Field.Label>}
107+
<Field.Control>
108+
<TuiTextInput
109+
ref={node => {
110+
ref.current = node
111+
// For input masking, grab the actual <input> element
112+
if (inputMask && node) {
113+
inputMaskRef.current = node.querySelector?.('input') ?? node
114+
}
115+
}}
116+
inputClassName={`tui-input-reset ${props.inputClassName ?? ''}`.trim()}
117+
size={props.size}
118+
type={props.type ?? 'text'}
119+
placeholder={props.placeholder}
120+
readOnly={props.readOnly}
121+
value={inputMask ? getInitialMaskValue() : value}
122+
onChange={event => setValue(event.target.value)}
123+
name={props.name ?? ''}
124+
prefix={prefix}
125+
suffix={suffix}
126+
/>
127+
</Field.Control>
128+
{props.description &&
129+
<Field.HelperText className={props.descriptionVisuallyHidden ? 'tui-visually-hidden' : undefined}>
130+
{props.description}
131+
</Field.HelperText>}
132+
</Field>
51133
</div>
52134
)
53135
}
54136

137+
/**
138+
* Strip prefix/suffix from a stored value for mask validation.
139+
*/
140+
function stripAffixes(value: string, prefix?: string, suffix?: string): string {
141+
let result = value
142+
if (prefix && result.startsWith(prefix)) {
143+
result = result.slice(prefix.length)
144+
}
145+
if (suffix && result.endsWith(suffix)) {
146+
result = result.slice(0, result.length - suffix.length)
147+
}
148+
return result
149+
}
150+
55151
export default TextField

assets/src/components/field/textarea/TextArea.tsx

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,79 @@
1-
import { useRef } from 'react'
2-
import { useTextField } from 'react-aria'
1+
import {
2+
useRef,
3+
useState,
4+
useEffect,
5+
lazy,
6+
Suspense,
7+
} from 'react'
38

9+
import { useTextField } from 'react-aria'
410
import { Description, Label } from '../../base'
511

12+
const DynamicEditor = lazy(() => import('../../dynamic/editor/DynamicEditor'))
13+
614
/**
15+
* Textarea with two routing paths:
16+
* 1. dynamic → ProseMirror-based DynamicEditor (multi-line)
17+
* 2. plain → native <textarea>
18+
*
719
* @see https://react-spectrum.adobe.com/react-aria/useTextField.html
820
*/
921

1022
const TextArea = (props) => {
1123
const ref = useRef()
24+
const hasDynamic = Boolean(props.dynamic)
25+
26+
const [value, setValue] = useState(props.value ?? '')
1227

1328
const { labelProps, inputProps, descriptionProps } = useTextField(
1429
{ ...props, inputElementType: 'textarea' },
1530
ref
1631
)
1732

33+
useEffect(() => {
34+
if (props.onChange) props.onChange(value)
35+
}, [value])
36+
37+
/**
38+
* Dynamic path — ProseMirror editor (multi-line)
39+
*/
40+
if (hasDynamic) {
41+
return (
42+
<div className="tf-text-area tf-text-area-dynamic">
43+
{props.label &&
44+
<Label labelProps={labelProps} parent={props}>
45+
{props.label}
46+
</Label>}
47+
<Suspense fallback={null}>
48+
<DynamicEditor
49+
value={value}
50+
onChange={setValue}
51+
dynamic={props.dynamic}
52+
name={props.name}
53+
placeholder={props.placeholder}
54+
readOnly={props.readOnly}
55+
singleLine={false}
56+
inputProps={inputProps}
57+
/>
58+
</Suspense>
59+
{props.description && (
60+
<Description descriptionProps={descriptionProps} parent={props}>
61+
{props.description}
62+
</Description>
63+
)}
64+
</div>
65+
)
66+
}
67+
68+
/**
69+
* Native path — standard <textarea>
70+
*/
1871
return (
1972
<div className='tf-text-area'>
20-
{props.label &&
21-
<Label labelProps={ labelProps } parent={ props }>
22-
{ props.label }
23-
</Label> }
73+
{props.label &&
74+
<Label labelProps={labelProps} parent={props}>
75+
{props.label}
76+
</Label>}
2477
<textarea
2578
{...inputProps}
2679
maxLength={props.maxlength}
@@ -31,8 +84,8 @@ const TextArea = (props) => {
3184
data-identifier={props.identifier ?? ''}
3285
></textarea>
3386
{props.description && (
34-
<Description descriptionProps={ descriptionProps } parent={ props }>
35-
{ props.description }
87+
<Description descriptionProps={descriptionProps} parent={props}>
88+
{props.description}
3689
</Description>
3790
)}
3891
</div>

tsconfig.tsbuildinfo

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"root":["./assets/src/control.tsx","./assets/src/element.tsx","./assets/src/config.ts","./assets/src/events.ts","./assets/src/fields.ts","./assets/src/global.ts","./assets/src/index.tsx","./assets/src/store.ts","./assets/src/types.ts","./assets/src/utils.ts","./assets/src/codemirror/index.ts","./assets/src/components/base/index.ts","./assets/src/components/base/button/button.stories.tsx","./assets/src/components/base/button/button.tsx","./assets/src/components/base/button/buttoncomparison.stories.tsx","./assets/src/components/base/button/legacybutton.stories.ts","./assets/src/components/base/button/legacybutton.tsx","./assets/src/components/base/dialog/dialog.tsx","./assets/src/components/base/expandable-panel/expandablepanel.tsx","./assets/src/components/base/field/description.tsx","./assets/src/components/base/field/label.tsx","./assets/src/components/base/list-box/listbox.tsx","./assets/src/components/base/list-box/option.tsx","./assets/src/components/base/list-box/renderchoices.tsx","./assets/src/components/base/list-box/section.tsx","./assets/src/components/base/modal/modal.tsx","./assets/src/components/base/modal/modaltrigger.tsx","./assets/src/components/base/notice/notice.tsx","./assets/src/components/base/popover/popover.tsx","./assets/src/components/base/tab/tab.tsx","./assets/src/components/base/title/title.tsx","./assets/src/components/base/tooltip/tooltip.tsx","./assets/src/components/base/tooltip/tooltiptrigger.tsx","./assets/src/components/base/wrapper/wrapper.tsx","./assets/src/components/conditional/conditiongroup.tsx","./assets/src/components/conditional/conditonalpanel.tsx","./assets/src/components/conditional/condition-fields.ts","./assets/src/components/dependent/dependendwrapper.tsx","./assets/src/components/dependent/utils.ts","./assets/src/components/dynamic/index.ts","./assets/src/components/dynamic/base-wrapper/basewrapper.tsx","./assets/src/components/dynamic/editor/dynamiceditor.tsx","./assets/src/components/dynamic/editor/index.ts","./assets/src/components/dynamic/field-wrapper/fieldwrapper.tsx","./assets/src/components/dynamic/settings-modal/dynamicfieldsettings.tsx","./assets/src/components/dynamic/settings-modal/index.ts","./assets/src/components/dynamic/text-input/textinput.tsx","./assets/src/components/field/index.ts","./assets/src/components/field/accordion/accordion.tsx","./assets/src/components/field/alignment-matrix/alignmentmatrix.tsx","./assets/src/components/field/border/border.tsx","./assets/src/components/field/button-group/buttongroup.tsx","./assets/src/components/field/button-group/buttonoption.tsx","./assets/src/components/field/checkbox/checkbox.tsx","./assets/src/components/field/code/code.tsx","./assets/src/components/field/code/editor.tsx","./assets/src/components/field/code/create.ts","./assets/src/components/field/color/color.tsx","./assets/src/components/field/color/colorarea.tsx","./assets/src/components/field/color/colorfield.tsx","./assets/src/components/field/color/colorpicker.tsx","./assets/src/components/field/color/colorslider.tsx","./assets/src/components/field/combo-box/combobox.tsx","./assets/src/components/field/combo-box/multiplecomboxbox.tsx","./assets/src/components/field/combo-box/async.tsx","./assets/src/components/field/combo-box/common.ts","./assets/src/components/field/combo-box/index.tsx","./assets/src/components/field/combo-box/layout/simple.tsx","./assets/src/components/field/combo-box/layout/simplemultiple.tsx","./assets/src/components/field/combo-box/layout/index.ts","./assets/src/components/field/date/date.tsx","./assets/src/components/field/date/datefield.tsx","./assets/src/components/field/date/datepicker.tsx","./assets/src/components/field/date/daterange.tsx","./assets/src/components/field/date/daterangepicker.tsx","./assets/src/components/field/date/datesegment.tsx","./assets/src/components/field/date/format.ts","./assets/src/components/field/date/index.tsx","./assets/src/components/field/date/calendar/calendar.tsx","./assets/src/components/field/date/calendar/calendarcell.tsx","./assets/src/components/field/date/calendar/calendargrid.tsx","./assets/src/components/field/date/calendar/daterangecalendarcontext.tsx","./assets/src/components/field/date/calendar/preset.tsx","./assets/src/components/field/dimensions/dimensions.tsx","./assets/src/components/field/dynamic-text/dynamictext.tsx","./assets/src/components/field/editor/tinymce.tsx","./assets/src/components/field/editor/index.tsx","./assets/src/components/field/editor/prosemirror/editor.tsx","./assets/src/components/field/editor/prosemirror/prosemirror.tsx","./assets/src/components/field/field-group/fieldgroup.tsx","./assets/src/components/field/field-group/fieldgroupitem.tsx","./assets/src/components/field/file/file.tsx","./assets/src/components/field/file/filepreview.tsx","./assets/src/components/field/gallery/gallery.tsx","./assets/src/components/field/gallery/imagepreview.tsx","./assets/src/components/field/gradient/gradient.tsx","./assets/src/components/field/hidden/inputhidden.tsx","./assets/src/components/field/list/list.tsx","./assets/src/components/field/number/number.tsx","./assets/src/components/field/radio/radio.tsx","./assets/src/components/field/radio/radiogroup.tsx","./assets/src/components/field/radio/index.tsx","./assets/src/components/field/select/multipleselect.tsx","./assets/src/components/field/select/select.tsx","./assets/src/components/field/select/index.tsx","./assets/src/components/field/simple-dimension/simpledimension.tsx","./assets/src/components/field/switch/switch.tsx","./assets/src/components/field/switch/index.tsx","./assets/src/components/field/tab/tab.tsx","./assets/src/components/field/text/text.stories.tsx","./assets/src/components/field/text/text.tsx","./assets/src/components/field/textarea/textarea.tsx","./assets/src/components/field/time-picker/timefield.tsx","./assets/src/components/field/time-picker/timepicker.tsx","./assets/src/components/render/renderwrapper.tsx","./assets/src/components/repeater/repeater.tsx","./assets/src/components/repeater/dispatcher.ts","./assets/src/components/repeater/strings.ts","./assets/src/components/repeater/common/bulkactions.tsx","./assets/src/components/repeater/common/item.tsx","./assets/src/components/repeater/common/helpers.ts","./assets/src/components/repeater/layout/index.ts","./assets/src/components/repeater/layout/advanced/advanced.tsx","./assets/src/components/repeater/layout/advanced/header.tsx","./assets/src/components/repeater/layout/bare/bare.tsx","./assets/src/components/repeater/layout/block/block.tsx","./assets/src/components/repeater/layout/tab/tab.tsx","./assets/src/components/repeater/layout/table/table.tsx","./assets/src/components/visibility/visibilitywrapper.tsx","./assets/src/dynamic-values/format.ts","./assets/src/dynamic-values/index.ts","./assets/src/example/combobox-layout.tsx","./assets/src/example/index.ts","./assets/src/example/register-custom-type.tsx","./assets/src/prosemirror/dynamic-text/index.ts","./assets/src/prosemirror/dynamic-text/schema.ts","./assets/src/prosemirror/dynamic-text/serialise.ts","./assets/src/prosemirror/dynamic-text/tokenise.ts","./assets/src/prosemirror/dynamic-text/use-prosemirror-editor.ts","./assets/src/prosemirror/dynamic-text/node-views/dynamicvalueview.ts","./assets/src/prosemirror/dynamic-text/plugins/dedup-ids.ts","./assets/src/prosemirror/dynamic-text/plugins/placeholder.ts","./assets/src/prosemirror/dynamic-text/plugins/replace-on-type.ts","./assets/src/prosemirror/dynamic-text/plugins/single-line.ts","./assets/src/requests/index.ts","./assets/src/requests/media.ts","./assets/src/utils/generate-id.ts","./assets/src/utils/input-mask.ts","./assets/src/utils/use-input-mask.ts","./assets/src/visibility/evaluate.ts","./assets/src/visibility/field.ts","./assets/src/visibility/index.ts","./.storybook/main.ts","./.storybook/manager.ts","./.storybook/preview.ts","./.storybook/tangibletheme.ts","./.storybook/decorators/context.tsx","./.storybook/decorators/globalcss.tsx","./.storybook/decorators/rtl.tsx"],"errors":true,"version":"5.9.3"}

0 commit comments

Comments
 (0)