-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathTextAreaField.tsx
More file actions
147 lines (140 loc) · 6.17 KB
/
Copy pathTextAreaField.tsx
File metadata and controls
147 lines (140 loc) · 6.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
import React, { useState } from 'react';
import {
Textarea,
EmptyValue,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
Button,
} from '@object-ui/components';
import { Maximize2, Check, X } from 'lucide-react';
import { FieldWidgetComponentProps } from './types';
/**
* TextAreaField - Multi-line text input widget
* Supports configurable row count and preserves whitespace in readonly mode.
*
* Mobile UX (round 3): when the FIELD METADATA carries `mobile_fullscreen:
* true`, an "expand" affordance opens a fullscreen edit dialog — much easier
* on phones than tapping a 4-row textarea trapped between other fields.
*
* That flag has exactly one producer: `ObjectForm` stamps it onto every
* long-text field when `ObjectFormSchema.mobile.fullscreenLongText` is set
* (`plugin-form/src/ObjectForm.tsx`). It reaches this widget on `field`, or
* on `schema` when the host is `SchemaRenderer` — the same pair every widget
* here resolves as `field || schema` (see `FieldWidgetComponentProps.schema`).
*
* There is deliberately NO widget-prop override. A `mobileFullscreen`
* (camelCase) prop was read here and written by nobody in the repo, and the
* snake_case `mobile_fullscreen` prop cannot arrive either: the form renderer
* strips both `mobile_fullscreen` and `fullscreen` from the props it forwards
* to registered widgets (`stripRegisteredFieldProps` in
* `components/src/renderers/form/form.tsx`). Reading keys nobody produces
* documented a contract that never held and invited the next author to pass a
* silently-ignored prop, so the reads are gone (objectui#3232). If a host
* override is ever genuinely needed, declare ONE key on
* `FieldWidgetComponentProps`, stop stripping it, and have a host pass it.
*/
export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
// Hooks must run before any early return (readonly) to keep hook order stable.
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [draft, setDraft] = useState(value ?? '');
if (readonly) {
return (
<div className="text-sm whitespace-pre-wrap">
{value || <EmptyValue />}
</div>
);
}
const textareaField = (field || (props as any).schema) as any;
const rows = textareaField?.rows || 4;
// Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
// objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
// spec-authored maxLength gave neither the textarea cap nor the counter.
const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
// Mobile fullscreen opt-in travels on the field metadata and nowhere else.
// `textareaField` already resolves the two carriers a host may use for that
// metadata (`field`, else `schema`), so this is a single read — a misspelled
// flag now has no read path to quietly catch it.
const showFullscreenButton = Boolean(textareaField?.mobile_fullscreen);
const openFullscreen = () => { setDraft(value ?? ''); setFullscreenOpen(true); };
const cancelFullscreen = () => setFullscreenOpen(false);
const commitFullscreen = () => { onChange(draft); setFullscreenOpen(false); };
const { inputType, ...domProps } = props as any;
return (
<div className="relative">
<Textarea
{...domProps}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={textareaField?.placeholder}
disabled={readonly || domProps.disabled}
rows={rows}
maxLength={maxLength}
aria-invalid={!!errorMessage}
className={domProps.className}
/>
{showFullscreenButton && (
<button
type="button"
onClick={openFullscreen}
className="absolute top-1.5 right-1.5 inline-flex items-center justify-center size-7 rounded-md bg-background/80 text-muted-foreground hover:text-foreground hover:bg-background border shadow-sm transition-colors"
aria-label={`Edit ${textareaField?.label ?? 'text'} fullscreen`}
data-testid="textarea-fullscreen-toggle"
>
<Maximize2 className="size-3.5" />
</button>
)}
{maxLength && (
<div
className="absolute bottom-2 right-2 text-xs text-gray-400"
aria-live="polite"
aria-label={`Character count: ${(value || '').length} of ${maxLength}`}
>
{(value || '').length}/{maxLength}
</div>
)}
{showFullscreenButton && (
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
<DialogContent
className="sm:max-w-3xl h-[100dvh] sm:h-[80vh] max-h-[100dvh] sm:max-h-[80vh] flex flex-col p-0 gap-0"
data-testid="textarea-fullscreen-dialog"
>
<DialogHeader className="p-4 border-b">
<DialogTitle className="text-base">
{textareaField?.label ?? 'Edit text'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 p-4">
<Textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
maxLength={maxLength}
placeholder={textareaField?.placeholder}
className="h-full min-h-full resize-none text-base"
data-testid="textarea-fullscreen-input"
/>
</div>
<DialogFooter className="p-3 border-t flex-row justify-between sm:justify-end gap-2">
{maxLength && (
<span className="text-xs text-muted-foreground self-center">
{draft.length}/{maxLength}
</span>
)}
<div className="flex gap-2 ml-auto">
<Button type="button" variant="ghost" onClick={cancelFullscreen}>
<X className="size-4 mr-1" /> Cancel
</Button>
<Button type="button" onClick={commitFullscreen} data-testid="textarea-fullscreen-save">
<Check className="size-4 mr-1" /> Done
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}