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 = () => { />
    -