Skip to content

Commit d372aa3

Browse files
authored
experimental: customize initial attributes per tag (#5209)
Ref #3632 Now different tags can have own initial attributes. Here added initial attributes to `<a>` and `<form>` which will be replaced with remix components on publish. Also overriden href, download and target types for links. <img width="246" alt="Screenshot 2025-05-15 at 16 21 00" src="https://github.com/user-attachments/assets/112c3c94-0c87-4fa5-936b-4def5cc4ee12" />
1 parent 24efd92 commit d372aa3

File tree

12 files changed

+141
-96
lines changed

12 files changed

+141
-96
lines changed

apps/builder/app/builder/features/settings-panel/property-label.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ import type { Prop } from "@webstudio-is/sdk";
1717
import { showAttribute } from "@webstudio-is/react-sdk";
1818
import { updateWebstudioData } from "~/shared/instance-utils";
1919
import { $selectedInstance } from "~/shared/awareness";
20-
import { $props, $registeredComponentPropsMetas } from "~/shared/nano-states";
21-
import { $selectedInstancePropsMetas, humanizeAttribute } from "./shared";
20+
import { $props } from "~/shared/nano-states";
21+
import {
22+
$selectedInstanceInitialPropNames,
23+
$selectedInstancePropsMetas,
24+
humanizeAttribute,
25+
} from "./shared";
2226

2327
const usePropMeta = (name: string) => {
2428
const store = useMemo(() => {
@@ -78,14 +82,8 @@ const deleteProp = (name: string) => {
7882
const useIsResettable = (name: string) => {
7983
const store = useMemo(() => {
8084
return computed(
81-
[$selectedInstance, $registeredComponentPropsMetas],
82-
(instance, propsMetas) => {
83-
if (name === showAttribute) {
84-
return true;
85-
}
86-
const metas = propsMetas.get(instance?.component ?? "");
87-
return metas?.initialProps?.includes(name);
88-
}
85+
[$selectedInstanceInitialPropNames],
86+
(initialPropNames) => name === showAttribute || initialPropNames.has(name)
8987
);
9088
}, [name]);
9189
return useStore(store);
@@ -102,10 +100,8 @@ export const PropertyLabel = ({
102100
const propMeta = usePropMeta(name);
103101
const prop = useProp(name);
104102
const label = propMeta?.label ?? humanizeAttribute(name);
105-
// 1. not existing properties cannot be deleted
106-
// 2. required properties cannot be deleted
107-
// 3. custom attributes like data-* do not have meta and can be deleted
108-
const isDeletable = prop && !propMeta?.required;
103+
// not existing properties cannot be deleted
104+
const isDeletable = prop !== undefined;
109105
const isResettable = useIsResettable(name);
110106
return (
111107
<Flex align="center" css={{ gap: theme.spacing[3] }}>

apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import {
2929
$isContentMode,
3030
$memoryProps,
3131
$selectedBreakpoint,
32-
$registeredComponentPropsMetas,
3332
} from "~/shared/nano-states";
3433
import { CollapsibleSectionWithAddButton } from "~/builder/shared/collapsible-section";
3534
import { serverSyncStore } from "~/shared/sync";
@@ -39,7 +38,10 @@ import { usePropsLogic, type PropAndMeta } from "./use-props-logic";
3938
import { AnimationSection } from "./animation/animation-section";
4039
import { $matchingBreakpoints } from "../../style-panel/shared/model";
4140
import { matchMediaBreakpoints } from "./match-media-breakpoints";
42-
import { $selectedInstancePropsMetas } from "../shared";
41+
import {
42+
$selectedInstanceInitialPropNames,
43+
$selectedInstancePropsMetas,
44+
} from "../shared";
4345

4446
type Item = {
4547
name: string;
@@ -107,22 +109,20 @@ const $availableProps = computed(
107109
[
108110
$selectedInstance,
109111
$props,
110-
$registeredComponentPropsMetas,
111112
$selectedInstancePropsMetas,
113+
$selectedInstanceInitialPropNames,
112114
],
113-
(instance, props, componentPropsMetas, instancePropsMetas) => {
115+
(instance, props, propsMetas, initialPropNames) => {
114116
const availableProps = new Map<Item["name"], Item>();
115-
for (const [name, { label, description }] of instancePropsMetas) {
117+
for (const [name, { label, description }] of propsMetas) {
116118
availableProps.set(name, { name, label, description });
117119
}
118120
if (instance === undefined) {
119121
return [];
120122
}
121-
const propsMetas = componentPropsMetas.get(instance.component);
122123
// remove initial props
123-
for (const name of propsMetas?.initialProps ?? []) {
124+
for (const name of initialPropNames) {
124125
availableProps.delete(name);
125-
availableProps.delete(reactPropsToStandardAttributes[name]);
126126
}
127127
// remove defined props
128128
for (const prop of props.values()) {

apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import {
1414
$isContentMode,
1515
$props,
1616
$registeredComponentMetas,
17-
$registeredComponentPropsMetas,
1817
} from "~/shared/nano-states";
1918
import { isRichText } from "~/shared/content-model";
2019
import { $selectedInstancePath } from "~/shared/awareness";
2120
import {
21+
$selectedInstanceInitialPropNames,
2222
$selectedInstancePropsMetas,
2323
showAttributeMeta,
2424
type PropValue,
@@ -198,10 +198,7 @@ export const usePropsLogic = ({
198198

199199
const propsMetas = useStore($selectedInstancePropsMetas);
200200

201-
const componentPropsMeta = useStore($registeredComponentPropsMetas).get(
202-
instance.component
203-
);
204-
const initialPropsNames = new Set(componentPropsMeta?.initialProps);
201+
const initialPropNames = useStore($selectedInstanceInitialPropNames);
205202

206203
const systemProps: PropAndMeta[] = [];
207204
// descendant component is not actually rendered
@@ -238,13 +235,8 @@ export const usePropsLogic = ({
238235
}
239236

240237
const initialProps: PropAndMeta[] = [];
241-
for (let name of initialPropsNames) {
242-
let propMeta = propsMetas.get(name);
243-
// className -> class
244-
if (propsMetas.has(reactPropsToStandardAttributes[name])) {
245-
name = reactPropsToStandardAttributes[name];
246-
propMeta = propsMetas.get(name);
247-
}
238+
for (const name of initialPropNames) {
239+
const propMeta = propsMetas.get(name);
248240

249241
if (propMeta === undefined) {
250242
console.error(

apps/builder/app/builder/features/settings-panel/shared.tsx

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -426,39 +426,29 @@ export const humanizeAttribute = (string: string) => {
426426
type Attribute = (typeof ariaAttributes)[number];
427427

428428
const attributeToMeta = (attribute: Attribute): PropMeta => {
429-
if (attribute.type === "string") {
430-
return {
431-
type: "string",
432-
control: "text",
433-
required: false,
434-
description: attribute.description,
435-
};
436-
}
429+
const required = attribute.required ?? false;
430+
const description = attribute.description;
437431
if (attribute.type === "select") {
438432
const options = attribute.options ?? [];
439433
return {
440434
type: "string",
441435
control: options.length > 3 ? "select" : "radio",
442-
required: false,
436+
required,
443437
options,
444-
description: attribute.description,
438+
description,
445439
};
446440
}
441+
if (attribute.type === "url") {
442+
return { type: "string", control: "url", required, description };
443+
}
444+
if (attribute.type === "string") {
445+
return { type: "string", control: "text", required, description };
446+
}
447447
if (attribute.type === "number") {
448-
return {
449-
type: "number",
450-
control: "number",
451-
required: false,
452-
description: attribute.description,
453-
};
448+
return { type: "number", control: "number", required, description };
454449
}
455450
if (attribute.type === "boolean") {
456-
return {
457-
type: "boolean",
458-
control: "boolean",
459-
required: false,
460-
description: attribute.description,
461-
};
451+
return { type: "boolean", control: "boolean", required, description };
462452
}
463453
attribute.type satisfies never;
464454
throw Error("impossible case");
@@ -509,3 +499,36 @@ export const $selectedInstancePropsMetas = computed(
509499
return new Map(Array.from(metas.entries()).reverse());
510500
}
511501
);
502+
503+
export const $selectedInstanceInitialPropNames = computed(
504+
[
505+
$selectedInstance,
506+
$registeredComponentPropsMetas,
507+
$selectedInstancePropsMetas,
508+
],
509+
(selectedInstance, componentPropsMetas, instancePropsMetas) => {
510+
const initialPropNames = new Set<string>();
511+
if (selectedInstance) {
512+
const initialProps =
513+
componentPropsMetas.get(selectedInstance.component)?.initialProps ?? [];
514+
for (const propName of initialProps) {
515+
// className -> class
516+
if (instancePropsMetas.has(reactPropsToStandardAttributes[propName])) {
517+
initialPropNames.add(reactPropsToStandardAttributes[propName]);
518+
} else {
519+
initialPropNames.add(propName);
520+
}
521+
}
522+
}
523+
for (const [propName, propMeta] of instancePropsMetas) {
524+
// skip show attribute which is added as system prop
525+
if (propName === showAttribute) {
526+
continue;
527+
}
528+
if (propMeta.required) {
529+
initialPropNames.add(propName);
530+
}
531+
}
532+
return initialPropNames;
533+
}
534+
);

apps/builder/app/shared/html.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ const spaceRegex = /^\s*$/;
2626
const getAttributeType = (
2727
attribute: (typeof ariaAttributes)[number]
2828
): "string" | "boolean" | "number" => {
29-
if (attribute.type === "string" || attribute.type === "select") {
29+
if (
30+
attribute.type === "string" ||
31+
attribute.type === "select" ||
32+
attribute.type === "url"
33+
) {
3034
return "string";
3135
}
3236
if (attribute.type === "number" || attribute.type === "boolean") {

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

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -233,18 +233,7 @@ export const registerComponentLibrary = ({
233233
const prevPropsMetas = $registeredComponentPropsMetas.get();
234234
const nextPropsMetas = new Map(prevPropsMetas);
235235
for (const [componentName, propsMeta] of Object.entries(propsMetas)) {
236-
const { initialProps = [], props } = propsMeta;
237-
const requiredProps: string[] = [];
238-
for (const [name, value] of Object.entries(props)) {
239-
if (value.required && initialProps.includes(name) === false) {
240-
requiredProps.push(name);
241-
}
242-
}
243-
nextPropsMetas.set(`${prefix}${componentName}`, {
244-
// order of initialProps must be preserved
245-
initialProps: [...initialProps, ...requiredProps],
246-
props,
247-
});
236+
nextPropsMetas.set(`${prefix}${componentName}`, propsMeta);
248237
}
249238
$registeredComponentPropsMetas.set(nextPropsMetas);
250239
};

packages/html-data/bin/aria.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ for (const [name, meta] of aria.entries()) {
7979
const ariaContent = `type Attribute = {
8080
name: string,
8181
description: string,
82-
type: 'string' | 'boolean' | 'number' | 'select',
82+
required?: boolean,
83+
type: 'string' | 'boolean' | 'number' | 'select' | 'url',
8384
options?: string[]
8485
}
8586

packages/html-data/bin/attributes.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ const validHtmlAttributes = new Set<string>();
2222
type Attribute = {
2323
name: string;
2424
description: string;
25-
type: "string" | "boolean" | "number" | "select";
25+
required?: boolean;
26+
type: "string" | "boolean" | "number" | "select" | "url";
2627
options?: string[];
2728
};
2829

@@ -48,10 +49,21 @@ const overrides: Record<
4849
options: undefined,
4950
},
5051
},
52+
a: {
53+
href: { type: "url", required: true },
54+
target: { required: true },
55+
download: { type: "boolean", required: true },
56+
},
57+
form: {
58+
action: { required: true },
59+
method: { required: true },
60+
enctype: { required: true },
61+
},
5162
area: {
5263
ping: false,
5364
},
5465
button: {
66+
type: { required: true },
5567
command: false,
5668
commandfor: false,
5769
popovertarget: false,
@@ -95,10 +107,13 @@ for (const row of rows) {
95107
if (value.endsWith(";")) {
96108
value = value.slice(0, -1);
97109
}
98-
const possibleOptions = value
110+
let possibleOptions = value
99111
.split(/\s*;\s*/)
100112
.filter((item) => item.startsWith('"') && item.endsWith('"'))
101113
.map((item) => item.slice(1, -1));
114+
if (value.includes("valid navigable target name or keyword")) {
115+
possibleOptions = ["_blank", "_self", "_parent", "_top"];
116+
}
102117
let type: "string" | "boolean" | "number" | "select" = "string";
103118
let options: undefined | string[];
104119
if (possibleOptions.length > 0) {
@@ -156,7 +171,8 @@ for (const tag of tags) {
156171
const attributesContent = `type Attribute = {
157172
name: string,
158173
description: string,
159-
type: 'string' | 'boolean' | 'number' | 'select',
174+
required?: boolean,
175+
type: 'string' | 'boolean' | 'number' | 'select' | 'url',
160176
options?: string[]
161177
}
162178
@@ -204,8 +220,8 @@ for (const entry of Object.entries(attributesByTag)) {
204220
for (const { name, type, options } of attributes) {
205221
const id = getId();
206222
const instanceId = instance.id;
207-
if (type === "string") {
208-
const prop: Prop = { id, instanceId, type, name, value: "" };
223+
if (type === "string" || type === "url") {
224+
const prop: Prop = { id, instanceId, type: "string", name, value: "" };
209225
props.set(prop.id, prop);
210226
continue;
211227
}
@@ -236,6 +252,7 @@ for (const entry of Object.entries(attributesByTag)) {
236252
props.set(prop.id, prop);
237253
continue;
238254
}
255+
(type) satisfies never;
239256
throw Error(`Unknown attribute ${name} with type ${type}`);
240257
}
241258
}

packages/html-data/src/__generated__/aria.ts

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)