Skip to content

Commit 4dfdf50

Browse files
authored
feat: migrate forms components to html elements (#5230)
Ref #3632 This is big one. So I switched to html elements whole forms section in components. Though here I introduced some deviation from specification **Textarea** Initial value is represented as text content ```html <textarea>content</textarea> ```` Though it is trickier to manage text content as state and all frameworks rely on value property of textarea dom interface. **Select** Similar story. There is no value attribute and state is represented by selected boolean attribute on options ```html <select> <option value="value1" selected></option> <option value="value2"></option> </select> ``` We replace selected attribute on option with value attribute on selected when paste. So basically all form elements are using "value" and "checked" attributes which can be easily mapped to html and all frameworks.
1 parent e9e0fb7 commit 4dfdf50

32 files changed

+492
-187
lines changed

apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
standardAttributesToReactProps,
4141
} from "@webstudio-is/react-sdk";
4242
import { rawTheme } from "@webstudio-is/design-system";
43+
import { Input, Select, Textarea } from "@webstudio-is/sdk-components-react";
4344
import {
4445
$propValuesByInstanceSelectorWithMemoryProps,
4546
getIndexedInstanceId,
@@ -475,6 +476,16 @@ export const WebstudioComponentCanvas = forwardRef<
475476

476477
if (instance.component === elementComponent) {
477478
Component = instance.tag ?? "div";
479+
// replace to enable uncontrolled state
480+
if (Component === "input") {
481+
Component = Input as AnyComponent;
482+
}
483+
if (Component === "textarea") {
484+
Component = Textarea as AnyComponent;
485+
}
486+
if (Component === "select") {
487+
Component = Select as AnyComponent;
488+
}
478489
}
479490

480491
if (instance.component === collectionComponent) {
@@ -670,6 +681,16 @@ export const WebstudioComponentPreview = forwardRef<
670681

671682
if (instance.component === elementComponent) {
672683
Component = instance.tag ?? "div";
684+
// replace to enable uncontrolled state
685+
if (Component === "input") {
686+
Component = Input as AnyComponent;
687+
}
688+
if (Component === "textarea") {
689+
Component = Textarea as AnyComponent;
690+
}
691+
if (Component === "select") {
692+
Component = Select as AnyComponent;
693+
}
673694
}
674695

675696
if (instance.component === blockComponent) {

apps/builder/app/shared/html.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,55 @@ test("optionally paste svg as html embed", () => {
249249
)
250250
);
251251
});
252+
253+
test("generate textarea element", () => {
254+
expect(
255+
generateFragmentFromHtml(`
256+
<div>
257+
<textarea>
258+
my text
259+
</textarea>
260+
</div>
261+
`)
262+
).toEqual(
263+
renderTemplate(
264+
<ws.element ws:tag="div">
265+
<ws.element ws:tag="textarea" value="my text" />
266+
</ws.element>
267+
)
268+
);
269+
});
270+
271+
test("generate select element", () => {
272+
expect(
273+
generateFragmentFromHtml(`
274+
<div>
275+
<select>
276+
<option value="one">One</option>
277+
<option value="two" selected>Two</option>
278+
</select>
279+
<select>
280+
<option>One</option>
281+
<option selected>Two</option>
282+
</select>
283+
</div>
284+
`)
285+
).toEqual(
286+
renderTemplate(
287+
<ws.element ws:tag="div">
288+
<ws.element ws:tag="select" value="two">
289+
<ws.element ws:tag="option" value="one">
290+
One
291+
</ws.element>
292+
<ws.element ws:tag="option" value="two">
293+
Two
294+
</ws.element>
295+
</ws.element>
296+
<ws.element ws:tag="select" value="Two">
297+
<ws.element ws:tag="option">One</ws.element>
298+
<ws.element ws:tag="option">Two</ws.element>
299+
</ws.element>
300+
</ws.element>
301+
)
302+
);
303+
});

apps/builder/app/shared/html.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ export const generateFragmentFromHtml = (
176176
createLocalStyles(instanceId, attr.value);
177177
continue;
178178
}
179+
// selected option is represented as fake value attribute on select element
180+
if (node.tagName === "option" && attr.name === "selected") {
181+
continue;
182+
}
179183
if (type === "string") {
180184
props.push({ id, instanceId, name, type, value: attr.value });
181185
continue;
@@ -195,6 +199,32 @@ export const generateFragmentFromHtml = (
195199
contentTags,
196200
richTextContentTags
197201
);
202+
if (node.tagName === "select") {
203+
for (const childNode of node.childNodes) {
204+
if (defaultTreeAdapter.isElementNode(childNode)) {
205+
if (
206+
childNode.tagName === "option" &&
207+
childNode.attrs.find((attr) => attr.name === "selected")
208+
) {
209+
const valueAttr = childNode.attrs.find(
210+
(attr) => attr.name === "value"
211+
);
212+
// if value attribute is omitted, the value is taken from the text content of the option element
213+
const childText = childNode.childNodes.find((childNode) =>
214+
defaultTreeAdapter.isTextNode(childNode)
215+
);
216+
// selected option is represented as fake value attribute on select element
217+
props.push({
218+
id: `${instance.id}:value`,
219+
instanceId: instance.id,
220+
name: "value",
221+
type: "string",
222+
value: valueAttr?.value ?? childText?.value.trim() ?? "",
223+
});
224+
}
225+
}
226+
}
227+
}
198228
for (const childNode of node.childNodes) {
199229
if (defaultTreeAdapter.isElementNode(childNode)) {
200230
const child = convertElementToInstance(childNode);
@@ -211,6 +241,18 @@ export const generateFragmentFromHtml = (
211241
// collapse spacing characters inside of text to avoid preserved newlines
212242
value: childNode.value.replaceAll(/\s+/g, " "),
213243
};
244+
// textarea content is initial value
245+
// and represented with fake value attribute
246+
if (node.tagName === "textarea") {
247+
props.push({
248+
id: `${instance.id}:value`,
249+
instanceId: instance.id,
250+
name: "value",
251+
type: "string",
252+
value: child.value.trim(),
253+
});
254+
continue;
255+
}
214256
// when element has content elements other than supported by rich text
215257
// wrap its text children with span, for example
216258
// <div>

apps/builder/app/shared/nano-states/components.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const subscribeComponentHooks = () => {
129129
id: instance.id,
130130
instanceKey: getInstanceKey(array.slice(index)),
131131
component: instance.component,
132+
tag: instance.tag,
132133
};
133134
}),
134135
});
@@ -147,6 +148,7 @@ export const subscribeComponentHooks = () => {
147148
id: instance.id,
148149
instanceKey: getInstanceKey(array.slice(index)),
149150
component: instance.component,
151+
tag: instance.tag,
150152
};
151153
}),
152154
});

packages/cli/src/framework-react-router.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export const createFramework = async (): Promise<Framework> => {
5656
metas,
5757
components,
5858
tags: {
59+
textarea: `${base}:Textarea`,
60+
input: `${base}:Input`,
61+
select: `${base}:Select`,
5962
body: `${reactRouter}:Body`,
6063
a: `${reactRouter}:Link`,
6164
form: `${reactRouter}:RemixForm`,

packages/cli/src/framework-remix.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export const createFramework = async (): Promise<Framework> => {
5656
metas,
5757
components,
5858
tags: {
59+
textarea: `${base}:Textarea`,
60+
input: `${base}:Input`,
61+
select: `${base}:Select`,
5962
body: `${remix}:Body`,
6063
a: `${remix}:Link`,
6164
form: `${remix}:RemixForm`,

packages/cli/src/framework-vike-ssg.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ export const createFramework = async (): Promise<Framework> => {
5353
return {
5454
metas,
5555
components,
56-
tags: {},
56+
tags: {
57+
textarea: `${base}:Textarea`,
58+
input: `${base}:Input`,
59+
select: `${base}:Select`,
60+
},
5761
html: ({ pagePath }: { pagePath: string }) => {
5862
// ignore dynamic pages in static export
5963
if (isPathnamePattern(pagePath)) {

packages/html-data/bin/attributes.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,51 @@ const overrides: Record<
7373
popovertarget: false,
7474
popovertargetaction: false,
7575
},
76+
label: {
77+
for: { required: true },
78+
},
7679
dialog: {
7780
closedby: false,
7881
},
7982
img: {
8083
ismap: false,
8184
},
8285
input: {
86+
name: { required: true },
87+
value: { required: true },
88+
checked: { required: true },
89+
type: { required: true },
90+
placeholder: { required: true },
91+
required: { required: true },
92+
autofocus: { required: true },
8393
alpha: false,
8494
colorspace: false,
8595
// react types have it only in textarea
8696
dirname: false,
8797
popovertarget: false,
8898
popovertargetaction: false,
8999
},
100+
textarea: {
101+
name: { required: true },
102+
placeholder: { required: true },
103+
required: { required: true },
104+
autofocus: { required: true },
105+
},
106+
select: {
107+
name: { required: true },
108+
required: { required: true },
109+
autofocus: { required: true },
110+
// mutltiple mode is not considered accessible
111+
// and we cannot express it in builder so easier to remove
112+
multiple: false,
113+
},
114+
option: {
115+
label: { required: true },
116+
value: { required: true },
117+
disabled: { required: true },
118+
// enforce fake value attribute on select element
119+
selected: false,
120+
},
90121
};
91122

92123
// Crawl WHATWG HTML.
@@ -99,6 +130,26 @@ const [tbody] = findTags(table, "tbody");
99130
const rows = findTags(tbody, "tr");
100131

101132
const attributesByTag: Record<string, Attribute[]> = {};
133+
// textarea does not have value attribute and text content is used as initial value
134+
// introduce fake value attribute to manage initial state similar to input
135+
attributesByTag.textarea = [
136+
{
137+
name: "value",
138+
description: "Value of the form control",
139+
type: "string",
140+
required: true,
141+
},
142+
];
143+
// select does not have value attribute and selected options are used as initial value
144+
// introduce fake value attribute to manage initial state similar to input
145+
attributesByTag.select = [
146+
{
147+
name: "value",
148+
description: "Value of the form control",
149+
type: "string",
150+
required: true,
151+
},
152+
];
102153

103154
for (const row of rows) {
104155
const attribute = getTextContent(row.childNodes[0]).trim();
@@ -118,6 +169,32 @@ for (const row of rows) {
118169
if (value.includes("valid navigable target name or keyword")) {
119170
possibleOptions = ["_blank", "_self", "_parent", "_top"];
120171
}
172+
if (value.includes("input type keyword")) {
173+
possibleOptions = [
174+
"hidden",
175+
"text",
176+
"search",
177+
"tel",
178+
"url",
179+
"email",
180+
"password",
181+
"date",
182+
"month",
183+
"week",
184+
"time",
185+
"datetime-local",
186+
"number",
187+
"range",
188+
"color",
189+
"checkbox",
190+
"radio",
191+
"file",
192+
"submit",
193+
"image",
194+
"reset",
195+
"button",
196+
];
197+
}
121198
let type: "string" | "boolean" | "number" | "select" = "string";
122199
let options: undefined | string[];
123200
if (possibleOptions.length > 0) {

packages/html-data/bin/elements.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,13 @@ const elementsByTag: Record<string, Element> = {};
4646
});
4747
const description = getTextContent(row.childNodes[1]);
4848
const categories = parseList(getTextContent(row.childNodes[2]));
49-
const children = parseList(getTextContent(row.childNodes[4]));
49+
let children = parseList(getTextContent(row.childNodes[4]));
5050
for (const tag of elements) {
51+
// textarea does not have value attribute and text content is used as initial value
52+
// introduce fake value attribute to manage initial state similar to input
53+
if (tag === "textarea") {
54+
children = [];
55+
}
5156
elementsByTag[tag] = {
5257
description,
5358
categories,

packages/html-data/src/__generated__/attributes-jsx-test.tsx

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

0 commit comments

Comments
 (0)