Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
52 changes: 52 additions & 0 deletions apps/builder/app/shared/html.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,55 @@ test("optionally paste svg as html embed", () => {
)
);
});

test("generate textarea element", () => {
expect(
generateFragmentFromHtml(`
<div>
<textarea>
my text
</textarea>
</div>
`)
).toEqual(
renderTemplate(
<ws.element ws:tag="div">
<ws.element ws:tag="textarea" value="my text" />
</ws.element>
)
);
});

test("generate select element", () => {
expect(
generateFragmentFromHtml(`
<div>
<select>
<option value="one">One</option>
<option value="two" selected>Two</option>
</select>
<select>
<option>One</option>
<option selected>Two</option>
</select>
</div>
`)
).toEqual(
renderTemplate(
<ws.element ws:tag="div">
<ws.element ws:tag="select" value="two">
<ws.element ws:tag="option" value="one">
One
</ws.element>
<ws.element ws:tag="option" value="two">
Two
</ws.element>
</ws.element>
<ws.element ws:tag="select" value="Two">
<ws.element ws:tag="option">One</ws.element>
<ws.element ws:tag="option">Two</ws.element>
</ws.element>
</ws.element>
)
);
});
42 changes: 42 additions & 0 deletions apps/builder/app/shared/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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
// <div>
Expand Down
2 changes: 2 additions & 0 deletions apps/builder/app/shared/nano-states/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const subscribeComponentHooks = () => {
id: instance.id,
instanceKey: getInstanceKey(array.slice(index)),
component: instance.component,
tag: instance.tag,
};
}),
});
Expand All @@ -147,6 +148,7 @@ export const subscribeComponentHooks = () => {
id: instance.id,
instanceKey: getInstanceKey(array.slice(index)),
component: instance.component,
tag: instance.tag,
};
}),
});
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/framework-react-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export const createFramework = async (): Promise<Framework> => {
metas,
components,
tags: {
textarea: `${base}:Textarea`,
input: `${base}:Input`,
select: `${base}:Select`,
body: `${reactRouter}:Body`,
a: `${reactRouter}:Link`,
form: `${reactRouter}:RemixForm`,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/framework-remix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export const createFramework = async (): Promise<Framework> => {
metas,
components,
tags: {
textarea: `${base}:Textarea`,
input: `${base}:Input`,
select: `${base}:Select`,
body: `${remix}:Body`,
a: `${remix}:Link`,
form: `${remix}:RemixForm`,
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/framework-vike-ssg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ export const createFramework = async (): Promise<Framework> => {
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)) {
Expand Down
77 changes: 77 additions & 0 deletions packages/html-data/bin/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,51 @@ const overrides: Record<
popovertarget: false,
popovertargetaction: false,
},
label: {
for: { required: true },
},
dialog: {
closedby: false,
},
img: {
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
dirname: false,
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.
Expand All @@ -99,6 +130,26 @@ const [tbody] = findTags(table, "tbody");
const rows = findTags(tbody, "tr");

const attributesByTag: Record<string, Attribute[]> = {};
// 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();
Expand All @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion packages/html-data/bin/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,13 @@ const elementsByTag: Record<string, Element> = {};
});
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,
Expand Down
7 changes: 4 additions & 3 deletions packages/html-data/src/__generated__/attributes-jsx-test.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading