Skip to content

refactor: add tag property control #5080

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 3, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ jobs:
const results = [
await assertSize('./fixtures/ssg/dist/client', 352),
await assertSize('./fixtures/react-router-netlify/build/client', 368),
await assertSize('./fixtures/webstudio-features/build/client', 1028),
await assertSize('./fixtures/webstudio-features/build/client', 1032),
]
for (const result of results) {
if (result.passed) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ControlProps } from "../shared";
import { JsonControl } from "./json";
import { TextContent } from "./text-content";
import { ResourceControl } from "./resource-control";
import { TagControl } from "./tag-control";

export const renderControl = ({
meta,
Expand All @@ -24,6 +25,10 @@ export const renderControl = ({
return <TextContent key={key} meta={meta} prop={prop} {...rest} />;
}

if (meta.control === "tag") {
return <TagControl key={key} meta={meta} prop={prop} {...rest} />;
}

// never render parameter props
if (prop?.type === "parameter") {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useStore } from "@nanostores/react";
import { Select } from "@webstudio-is/design-system";
import { $selectedInstance } from "~/shared/awareness";
import { updateWebstudioData } from "~/shared/instance-utils";
import { type ControlProps, VerticalLayout } from "../shared";
import { FieldLabel } from "../property-label";

export const TagControl = ({ meta, prop }: ControlProps<"tag">) => {
const instance = useStore($selectedInstance);
const propTag = prop?.type === "string" ? prop.value : undefined;
const instanceTag = instance?.tag;
const defaultTag = meta.options[0];
const value = propTag ?? instanceTag ?? defaultTag;
return (
<VerticalLayout
label={
<FieldLabel description="Use this property to change the HTML tag of this element to semantically structure and describe the content of a webpage. This can be important for accessibility tools and search engine optimization.">
Tag
</FieldLabel>
}
>
<Select
fullWidth
value={value}
options={meta.options}
onChange={(value) => {
if (instance === undefined) {
return;
}
const instanceId = instance.id;
updateWebstudioData((data) => {
// clean legacy <Box tag> and <Text tag>
if (prop) {
data.props.delete(prop.id);
}
const instance = data.instances.get(instanceId);
if (instance) {
instance.tag = value;
}
});
}}
/>
</VerticalLayout>
);
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { micromark } from "micromark";
import { useMemo, useState } from "react";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
Expand All @@ -18,7 +19,6 @@ import { updateWebstudioData } from "~/shared/instance-utils";
import { $selectedInstance } from "~/shared/awareness";
import { $props, $registeredComponentPropsMetas } from "~/shared/nano-states";
import { humanizeAttribute, showAttributeMeta } from "./shared";
import { micromark } from "micromark";

const usePropMeta = (name: string) => {
const store = useMemo(() => {
Expand Down Expand Up @@ -230,6 +230,11 @@ export const FieldLabel = ({
</Text>
{description && (
<Text
css={{
"> *": {
marginTop: 0,
},
}}
dangerouslySetInnerHTML={{ __html: micromark(description) }}
></Text>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
editablePlaceholderAttribute,
editingPlaceholderVariable,
} from "~/canvas/shared/styles";
import { tagProperty } from "@webstudio-is/sdk/runtime";

const ContentEditable = ({
placeholder,
Expand Down Expand Up @@ -280,9 +281,17 @@ const useInstanceProps = (instanceSelector: InstanceSelector) => {
const [instanceId] = instanceSelector;
const $instancePropsObject = useMemo(() => {
return computed(
[$propValuesByInstanceSelectorWithMemoryProps, $indexesWithinAncestors],
(propValuesByInstanceSelector, indexesWithinAncestors) => {
[
$propValuesByInstanceSelectorWithMemoryProps,
$instances,
$indexesWithinAncestors,
],
(propValuesByInstanceSelector, instances, indexesWithinAncestors) => {
const instancePropsObject: Record<Prop["name"], unknown> = {};
const tag = instances.get(instanceId)?.tag;
if (tag !== undefined) {
instancePropsObject[tagProperty] = tag;
}
const index = indexesWithinAncestors.get(instanceId);
if (index !== undefined) {
instancePropsObject[indexAttribute] = index.toString();
Expand Down
4 changes: 1 addition & 3 deletions apps/builder/app/shared/style-object-model.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,11 @@ const createModel = ({
values: prop.value.split(" "),
});
}
if (prop.name === "ws:tag" && prop.type === "string") {
instanceTags.set(prop.instanceId, prop.value as HtmlTags);
}
}
const instanceComponents = new Map<string, string>();
for (const instance of instances.values()) {
instanceComponents.set(instance.id, instance.component);
instanceTags.set(instance.id, (instance.tag ?? "div") as HtmlTags);
}
const presetStyles = new Map<string, StyleValue>();
for (const [componentName, css] of Object.entries(presets ?? {})) {
Expand Down
29 changes: 29 additions & 0 deletions packages/react-sdk/src/component-generator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1272,3 +1272,32 @@ test("render empty component when no instances found", () => {
)
);
});

test("render tag property on components", () => {
expect(
generateWebstudioComponent({
classesMap: new Map(),
scope: createScope(),
name: "Page",
rootInstanceId: "bodyId",
parameters: [],
metas: new Map(),
...renderData(
<$.Body ws:id="bodyId">
<$.Box ws:id="spanId" ws:tag="span"></$.Box>
</$.Body>
),
})
).toEqual(
validateJSX(
clear(`
const Page = () => {
return <Body>
<Box
data-ws-tag="span" />
</Body>
}
`)
)
);
});
4 changes: 4 additions & 0 deletions packages/react-sdk/src/component-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
descendantComponent,
getIndexesWithinAncestors,
} from "@webstudio-is/sdk";
import { tagProperty } from "@webstudio-is/sdk/runtime";
import { indexAttribute, isAttributeNameSafe, showAttribute } from "./props";

/**
Expand Down Expand Up @@ -171,6 +172,9 @@ export const generateJsxElement = ({
if (index !== undefined) {
generatedProps += `\n${indexAttribute}="${index}"`;
}
if (instance.tag !== undefined) {
generatedProps += `\n${tagProperty}=${JSON.stringify(instance.tag)}`;
}

let conditionValue: undefined | string;
let collectionDataValue: undefined | string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const meta: TemplateMeta = {
<$.HtmlEmbed ws:label="Indicator Icon" code={CheckMarkIcon} />
</radix.CheckboxIndicator>
</radix.Checkbox>
<$.Text ws:label="Checkbox Label" tag="span">
<$.Text ws:label="Checkbox Label" ws:tag="span">
{new PlaceholderValue("Checkbox")}
</$.Text>
</radix.Label>
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-components-react-radix/src/sheet.template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const meta: TemplateMeta = {
flex-grow: 1;
`}
>
<$.Box ws:label="Navigation" tag="nav" role="navigation">
<$.Box ws:label="Navigation" ws:tag="nav">
<$.Box
ws:label="Sheet Header"
ws:style={css`
Expand Down
21 changes: 1 addition & 20 deletions packages/sdk-components-react/src/__generated__/box.props.ts

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

22 changes: 6 additions & 16 deletions packages/sdk-components-react/src/box.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
import {
createElement,
forwardRef,
createElement,
type ElementRef,
type ComponentProps,
} from "react";
import { getTagFromComponentProps } from "@webstudio-is/sdk/runtime";

export const defaultTag = "div";
const defaultTag = "div";

// We don't want to enable all tags because Box is usually a container and we have specific components for many tags.
type Props = ComponentProps<typeof defaultTag> & {
/** Use this property to change the HTML tag of this element to semantically structure and describe the content of a webpage. This can be important for accessibility tools and search engine optimization. */
tag?:
| "div"
| "header"
| "footer"
| "nav"
| "main"
| "section"
| "article"
| "aside"
| "address"
| "figure";
tag?: string;
};

export const Box = forwardRef<ElementRef<typeof defaultTag>, Props>(
({ tag = defaultTag, ...props }, ref) => {
({ tag: legacyTag, ...props }, ref) => {
const tag = getTagFromComponentProps(props) ?? legacyTag ?? defaultTag;
return createElement(tag, { ...props, ref });
}
);
Expand Down
55 changes: 34 additions & 21 deletions packages/sdk-components-react/src/box.ws.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { ComponentProps } from "react";
import { BoxIcon } from "@webstudio-is/icons/svg";
import {
defaultStates,
type PresetStyle,
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
Expand All @@ -19,22 +17,6 @@ import {
section,
} from "@webstudio-is/sdk/normalize.css";
import { props } from "./__generated__/box.props";
import type { Box } from "./box";

type BoxTags = NonNullable<ComponentProps<typeof Box>["tag"]>;

const presetStyle = {
div,
address,
article,
aside,
figure,
footer,
header,
main,
nav,
section,
} satisfies PresetStyle<BoxTags>;

export const meta: WsComponentMeta = {
category: "general",
Expand All @@ -43,11 +25,42 @@ export const meta: WsComponentMeta = {
"A container for content. By default this is a Div, but the tag can be changed in settings.",
icon: BoxIcon,
states: defaultStates,
presetStyle,
presetStyle: {
div,
address,
article,
aside,
figure,
footer,
header,
main,
nav,
section,
},
order: 0,
};

export const propsMeta: WsComponentPropsMeta = {
props,
initialProps: ["id", "className", "tag"],
props: {
...props,
tag: {
required: true,
control: "tag",
type: "string",
options: [
"div",
"header",
"footer",
"nav",
"main",
"section",
"article",
"aside",
"address",
"figure",
"span",
],
},
},
initialProps: ["tag", "id", "className"],
};
2 changes: 1 addition & 1 deletion packages/sdk-components-react/src/checkbox.template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const meta: TemplateMeta = {
template: (
<$.Label ws:label="Checkbox Field">
<$.Checkbox />
<$.Text ws:label="Checkbox Label" tag="span">
<$.Text ws:label="Checkbox Label" ws:tag="span">
{new PlaceholderValue("Checkbox")}
</$.Text>
</$.Label>
Expand Down
22 changes: 11 additions & 11 deletions packages/sdk-components-react/src/heading.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { forwardRef, type ElementRef, type ComponentProps } from "react";
import {
forwardRef,
type ElementRef,
type ComponentProps,
createElement,
} from "react";
import { getTagFromComponentProps } from "@webstudio-is/sdk/runtime";

const defaultTag = "h1";

type Props = ComponentProps<typeof defaultTag> & {
/** Use HTML heading levels (h1-h6) to structure content hierarchically, with h1 as the main title and subsequent levels representing sub-sections. Maintain a logical order and avoid skipping levels to ensure consistent and meaningful organization. */
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
tag?: string;
};

export const Heading = forwardRef<ElementRef<typeof defaultTag>, Props>(
({ tag = defaultTag, children, ...props }, ref) => {
// Can't map it in the destricturing, default type won't be generated correctly
const Tag = tag;
return (
<Tag {...props} ref={ref}>
{children}
</Tag>
);
({ tag: legacyTag, ...props }, ref) => {
const tag = getTagFromComponentProps(props) ?? legacyTag ?? defaultTag;
return createElement(tag, { ...props, ref });
}
);

Expand Down
Loading