diff --git a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx
index 5405f9c3bb7a..3e8b598bc745 100644
--- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx
+++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx
@@ -40,6 +40,7 @@ import {
standardAttributesToReactProps,
} from "@webstudio-is/react-sdk";
import { rawTheme } from "@webstudio-is/design-system";
+import { Input, Select, Textarea } from "@webstudio-is/sdk-components-react";
import {
$propValuesByInstanceSelectorWithMemoryProps,
getIndexedInstanceId,
@@ -475,6 +476,16 @@ export const WebstudioComponentCanvas = forwardRef<
if (instance.component === elementComponent) {
Component = instance.tag ?? "div";
+ // replace to enable uncontrolled state
+ if (Component === "input") {
+ Component = Input as AnyComponent;
+ }
+ if (Component === "textarea") {
+ Component = Textarea as AnyComponent;
+ }
+ if (Component === "select") {
+ Component = Select as AnyComponent;
+ }
}
if (instance.component === collectionComponent) {
@@ -670,6 +681,16 @@ export const WebstudioComponentPreview = forwardRef<
if (instance.component === elementComponent) {
Component = instance.tag ?? "div";
+ // replace to enable uncontrolled state
+ if (Component === "input") {
+ Component = Input as AnyComponent;
+ }
+ if (Component === "textarea") {
+ Component = Textarea as AnyComponent;
+ }
+ if (Component === "select") {
+ Component = Select as AnyComponent;
+ }
}
if (instance.component === blockComponent) {
diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx
index 9458c0abca38..b36115dd4f97 100644
--- a/apps/builder/app/shared/html.test.tsx
+++ b/apps/builder/app/shared/html.test.tsx
@@ -249,3 +249,55 @@ test("optionally paste svg as html embed", () => {
)
);
});
+
+test("generate textarea element", () => {
+ expect(
+ generateFragmentFromHtml(`
+
+
+
+ `)
+ ).toEqual(
+ renderTemplate(
+
+
+
+ )
+ );
+});
+
+test("generate select element", () => {
+ expect(
+ generateFragmentFromHtml(`
+
+
+
+
+ `)
+ ).toEqual(
+ renderTemplate(
+
+
+
+ One
+
+
+ Two
+
+
+
+ One
+ Two
+
+
+ )
+ );
+});
diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts
index 5bb517367553..0face7ba30d4 100644
--- a/apps/builder/app/shared/html.ts
+++ b/apps/builder/app/shared/html.ts
@@ -176,6 +176,10 @@ export const generateFragmentFromHtml = (
createLocalStyles(instanceId, attr.value);
continue;
}
+ // selected option is represented as fake value attribute on select element
+ if (node.tagName === "option" && attr.name === "selected") {
+ continue;
+ }
if (type === "string") {
props.push({ id, instanceId, name, type, value: attr.value });
continue;
@@ -195,6 +199,32 @@ export const generateFragmentFromHtml = (
contentTags,
richTextContentTags
);
+ if (node.tagName === "select") {
+ for (const childNode of node.childNodes) {
+ if (defaultTreeAdapter.isElementNode(childNode)) {
+ if (
+ childNode.tagName === "option" &&
+ childNode.attrs.find((attr) => attr.name === "selected")
+ ) {
+ const valueAttr = childNode.attrs.find(
+ (attr) => attr.name === "value"
+ );
+ // if value attribute is omitted, the value is taken from the text content of the option element
+ const childText = childNode.childNodes.find((childNode) =>
+ defaultTreeAdapter.isTextNode(childNode)
+ );
+ // selected option is represented as fake value attribute on select element
+ props.push({
+ id: `${instance.id}:value`,
+ instanceId: instance.id,
+ name: "value",
+ type: "string",
+ value: valueAttr?.value ?? childText?.value.trim() ?? "",
+ });
+ }
+ }
+ }
+ }
for (const childNode of node.childNodes) {
if (defaultTreeAdapter.isElementNode(childNode)) {
const child = convertElementToInstance(childNode);
@@ -211,6 +241,18 @@ export const generateFragmentFromHtml = (
// collapse spacing characters inside of text to avoid preserved newlines
value: childNode.value.replaceAll(/\s+/g, " "),
};
+ // textarea content is initial value
+ // and represented with fake value attribute
+ if (node.tagName === "textarea") {
+ props.push({
+ id: `${instance.id}:value`,
+ instanceId: instance.id,
+ name: "value",
+ type: "string",
+ value: child.value.trim(),
+ });
+ continue;
+ }
// when element has content elements other than supported by rich text
// wrap its text children with span, for example
//
diff --git a/apps/builder/app/shared/nano-states/components.ts b/apps/builder/app/shared/nano-states/components.ts
index 2572ae0ece48..63e0d2e3b113 100644
--- a/apps/builder/app/shared/nano-states/components.ts
+++ b/apps/builder/app/shared/nano-states/components.ts
@@ -129,6 +129,7 @@ export const subscribeComponentHooks = () => {
id: instance.id,
instanceKey: getInstanceKey(array.slice(index)),
component: instance.component,
+ tag: instance.tag,
};
}),
});
@@ -147,6 +148,7 @@ export const subscribeComponentHooks = () => {
id: instance.id,
instanceKey: getInstanceKey(array.slice(index)),
component: instance.component,
+ tag: instance.tag,
};
}),
});
diff --git a/packages/cli/src/framework-react-router.ts b/packages/cli/src/framework-react-router.ts
index 0d007e43a246..bd325a44a083 100644
--- a/packages/cli/src/framework-react-router.ts
+++ b/packages/cli/src/framework-react-router.ts
@@ -56,6 +56,9 @@ export const createFramework = async (): Promise
=> {
metas,
components,
tags: {
+ textarea: `${base}:Textarea`,
+ input: `${base}:Input`,
+ select: `${base}:Select`,
body: `${reactRouter}:Body`,
a: `${reactRouter}:Link`,
form: `${reactRouter}:RemixForm`,
diff --git a/packages/cli/src/framework-remix.ts b/packages/cli/src/framework-remix.ts
index d03a527c2b09..6d5ef3b1f217 100644
--- a/packages/cli/src/framework-remix.ts
+++ b/packages/cli/src/framework-remix.ts
@@ -56,6 +56,9 @@ export const createFramework = async (): Promise => {
metas,
components,
tags: {
+ textarea: `${base}:Textarea`,
+ input: `${base}:Input`,
+ select: `${base}:Select`,
body: `${remix}:Body`,
a: `${remix}:Link`,
form: `${remix}:RemixForm`,
diff --git a/packages/cli/src/framework-vike-ssg.ts b/packages/cli/src/framework-vike-ssg.ts
index 148c59ea7be0..0f2876a9cc1f 100644
--- a/packages/cli/src/framework-vike-ssg.ts
+++ b/packages/cli/src/framework-vike-ssg.ts
@@ -53,7 +53,11 @@ export const createFramework = async (): Promise => {
return {
metas,
components,
- tags: {},
+ tags: {
+ textarea: `${base}:Textarea`,
+ input: `${base}:Input`,
+ select: `${base}:Select`,
+ },
html: ({ pagePath }: { pagePath: string }) => {
// ignore dynamic pages in static export
if (isPathnamePattern(pagePath)) {
diff --git a/packages/html-data/bin/attributes.ts b/packages/html-data/bin/attributes.ts
index 4598df5f0aab..33f45d543b0b 100644
--- a/packages/html-data/bin/attributes.ts
+++ b/packages/html-data/bin/attributes.ts
@@ -73,6 +73,9 @@ const overrides: Record<
popovertarget: false,
popovertargetaction: false,
},
+ label: {
+ for: { required: true },
+ },
dialog: {
closedby: false,
},
@@ -80,6 +83,13 @@ const overrides: Record<
ismap: false,
},
input: {
+ name: { required: true },
+ value: { required: true },
+ checked: { required: true },
+ type: { required: true },
+ placeholder: { required: true },
+ required: { required: true },
+ autofocus: { required: true },
alpha: false,
colorspace: false,
// react types have it only in textarea
@@ -87,6 +97,27 @@ const overrides: Record<
popovertarget: false,
popovertargetaction: false,
},
+ textarea: {
+ name: { required: true },
+ placeholder: { required: true },
+ required: { required: true },
+ autofocus: { required: true },
+ },
+ select: {
+ name: { required: true },
+ required: { required: true },
+ autofocus: { required: true },
+ // mutltiple mode is not considered accessible
+ // and we cannot express it in builder so easier to remove
+ multiple: false,
+ },
+ option: {
+ label: { required: true },
+ value: { required: true },
+ disabled: { required: true },
+ // enforce fake value attribute on select element
+ selected: false,
+ },
};
// Crawl WHATWG HTML.
@@ -99,6 +130,26 @@ const [tbody] = findTags(table, "tbody");
const rows = findTags(tbody, "tr");
const attributesByTag: Record = {};
+// textarea does not have value attribute and text content is used as initial value
+// introduce fake value attribute to manage initial state similar to input
+attributesByTag.textarea = [
+ {
+ name: "value",
+ description: "Value of the form control",
+ type: "string",
+ required: true,
+ },
+];
+// select does not have value attribute and selected options are used as initial value
+// introduce fake value attribute to manage initial state similar to input
+attributesByTag.select = [
+ {
+ name: "value",
+ description: "Value of the form control",
+ type: "string",
+ required: true,
+ },
+];
for (const row of rows) {
const attribute = getTextContent(row.childNodes[0]).trim();
@@ -118,6 +169,32 @@ for (const row of rows) {
if (value.includes("valid navigable target name or keyword")) {
possibleOptions = ["_blank", "_self", "_parent", "_top"];
}
+ if (value.includes("input type keyword")) {
+ possibleOptions = [
+ "hidden",
+ "text",
+ "search",
+ "tel",
+ "url",
+ "email",
+ "password",
+ "date",
+ "month",
+ "week",
+ "time",
+ "datetime-local",
+ "number",
+ "range",
+ "color",
+ "checkbox",
+ "radio",
+ "file",
+ "submit",
+ "image",
+ "reset",
+ "button",
+ ];
+ }
let type: "string" | "boolean" | "number" | "select" = "string";
let options: undefined | string[];
if (possibleOptions.length > 0) {
diff --git a/packages/html-data/bin/elements.ts b/packages/html-data/bin/elements.ts
index d6fb3d4e6d59..f04ac57fc1d6 100644
--- a/packages/html-data/bin/elements.ts
+++ b/packages/html-data/bin/elements.ts
@@ -46,8 +46,13 @@ const elementsByTag: Record = {};
});
const description = getTextContent(row.childNodes[1]);
const categories = parseList(getTextContent(row.childNodes[2]));
- const children = parseList(getTextContent(row.childNodes[4]));
+ let children = parseList(getTextContent(row.childNodes[4]));
for (const tag of elements) {
+ // textarea does not have value attribute and text content is used as initial value
+ // introduce fake value attribute to manage initial state similar to input
+ if (tag === "textarea") {
+ children = [];
+ }
elementsByTag[tag] = {
description,
categories,
diff --git a/packages/html-data/src/__generated__/attributes-jsx-test.tsx b/packages/html-data/src/__generated__/attributes-jsx-test.tsx
index 39e234c2a602..d9d17dc1b351 100644
--- a/packages/html-data/src/__generated__/attributes-jsx-test.tsx
+++ b/packages/html-data/src/__generated__/attributes-jsx-test.tsx
@@ -146,7 +146,7 @@ const Page = () => {
src={""}
step={0}
title={""}
- type={""}
+ type={"hidden"}
value={""}
width={0}
/>
@@ -172,15 +172,15 @@ const Page = () => {
/>
-
+