diff --git a/apps/builder/app/builder/features/settings-panel/property-label.tsx b/apps/builder/app/builder/features/settings-panel/property-label.tsx index a76151ea4cef..d3dfb3f20e8f 100644 --- a/apps/builder/app/builder/features/settings-panel/property-label.tsx +++ b/apps/builder/app/builder/features/settings-panel/property-label.tsx @@ -17,8 +17,12 @@ import type { Prop } from "@webstudio-is/sdk"; import { showAttribute } from "@webstudio-is/react-sdk"; import { updateWebstudioData } from "~/shared/instance-utils"; import { $selectedInstance } from "~/shared/awareness"; -import { $props, $registeredComponentPropsMetas } from "~/shared/nano-states"; -import { $selectedInstancePropsMetas, humanizeAttribute } from "./shared"; +import { $props } from "~/shared/nano-states"; +import { + $selectedInstanceInitialPropNames, + $selectedInstancePropsMetas, + humanizeAttribute, +} from "./shared"; const usePropMeta = (name: string) => { const store = useMemo(() => { @@ -78,14 +82,8 @@ const deleteProp = (name: string) => { const useIsResettable = (name: string) => { const store = useMemo(() => { return computed( - [$selectedInstance, $registeredComponentPropsMetas], - (instance, propsMetas) => { - if (name === showAttribute) { - return true; - } - const metas = propsMetas.get(instance?.component ?? ""); - return metas?.initialProps?.includes(name); - } + [$selectedInstanceInitialPropNames], + (initialPropNames) => name === showAttribute || initialPropNames.has(name) ); }, [name]); return useStore(store); @@ -102,10 +100,8 @@ export const PropertyLabel = ({ const propMeta = usePropMeta(name); const prop = useProp(name); const label = propMeta?.label ?? humanizeAttribute(name); - // 1. not existing properties cannot be deleted - // 2. required properties cannot be deleted - // 3. custom attributes like data-* do not have meta and can be deleted - const isDeletable = prop && !propMeta?.required; + // not existing properties cannot be deleted + const isDeletable = prop !== undefined; const isResettable = useIsResettable(name); return ( diff --git a/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx b/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx index 1b62037d9100..cfe1a9ccfdc6 100644 --- a/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx +++ b/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx @@ -29,7 +29,6 @@ import { $isContentMode, $memoryProps, $selectedBreakpoint, - $registeredComponentPropsMetas, } from "~/shared/nano-states"; import { CollapsibleSectionWithAddButton } from "~/builder/shared/collapsible-section"; import { serverSyncStore } from "~/shared/sync"; @@ -39,7 +38,10 @@ import { usePropsLogic, type PropAndMeta } from "./use-props-logic"; import { AnimationSection } from "./animation/animation-section"; import { $matchingBreakpoints } from "../../style-panel/shared/model"; import { matchMediaBreakpoints } from "./match-media-breakpoints"; -import { $selectedInstancePropsMetas } from "../shared"; +import { + $selectedInstanceInitialPropNames, + $selectedInstancePropsMetas, +} from "../shared"; type Item = { name: string; @@ -107,22 +109,20 @@ const $availableProps = computed( [ $selectedInstance, $props, - $registeredComponentPropsMetas, $selectedInstancePropsMetas, + $selectedInstanceInitialPropNames, ], - (instance, props, componentPropsMetas, instancePropsMetas) => { + (instance, props, propsMetas, initialPropNames) => { const availableProps = new Map(); - for (const [name, { label, description }] of instancePropsMetas) { + for (const [name, { label, description }] of propsMetas) { availableProps.set(name, { name, label, description }); } if (instance === undefined) { return []; } - const propsMetas = componentPropsMetas.get(instance.component); // remove initial props - for (const name of propsMetas?.initialProps ?? []) { + for (const name of initialPropNames) { availableProps.delete(name); - availableProps.delete(reactPropsToStandardAttributes[name]); } // remove defined props for (const prop of props.values()) { diff --git a/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts b/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts index 1c2aabb9a61d..a2196ec89202 100644 --- a/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts +++ b/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts @@ -14,11 +14,11 @@ import { $isContentMode, $props, $registeredComponentMetas, - $registeredComponentPropsMetas, } from "~/shared/nano-states"; import { isRichText } from "~/shared/content-model"; import { $selectedInstancePath } from "~/shared/awareness"; import { + $selectedInstanceInitialPropNames, $selectedInstancePropsMetas, showAttributeMeta, type PropValue, @@ -198,10 +198,7 @@ export const usePropsLogic = ({ const propsMetas = useStore($selectedInstancePropsMetas); - const componentPropsMeta = useStore($registeredComponentPropsMetas).get( - instance.component - ); - const initialPropsNames = new Set(componentPropsMeta?.initialProps); + const initialPropNames = useStore($selectedInstanceInitialPropNames); const systemProps: PropAndMeta[] = []; // descendant component is not actually rendered @@ -238,13 +235,8 @@ export const usePropsLogic = ({ } const initialProps: PropAndMeta[] = []; - for (let name of initialPropsNames) { - let propMeta = propsMetas.get(name); - // className -> class - if (propsMetas.has(reactPropsToStandardAttributes[name])) { - name = reactPropsToStandardAttributes[name]; - propMeta = propsMetas.get(name); - } + for (const name of initialPropNames) { + const propMeta = propsMetas.get(name); if (propMeta === undefined) { console.error( diff --git a/apps/builder/app/builder/features/settings-panel/shared.tsx b/apps/builder/app/builder/features/settings-panel/shared.tsx index eafaff371af4..466b0c5fab2c 100644 --- a/apps/builder/app/builder/features/settings-panel/shared.tsx +++ b/apps/builder/app/builder/features/settings-panel/shared.tsx @@ -426,39 +426,29 @@ export const humanizeAttribute = (string: string) => { type Attribute = (typeof ariaAttributes)[number]; const attributeToMeta = (attribute: Attribute): PropMeta => { - if (attribute.type === "string") { - return { - type: "string", - control: "text", - required: false, - description: attribute.description, - }; - } + const required = attribute.required ?? false; + const description = attribute.description; if (attribute.type === "select") { const options = attribute.options ?? []; return { type: "string", control: options.length > 3 ? "select" : "radio", - required: false, + required, options, - description: attribute.description, + description, }; } + if (attribute.type === "url") { + return { type: "string", control: "url", required, description }; + } + if (attribute.type === "string") { + return { type: "string", control: "text", required, description }; + } if (attribute.type === "number") { - return { - type: "number", - control: "number", - required: false, - description: attribute.description, - }; + return { type: "number", control: "number", required, description }; } if (attribute.type === "boolean") { - return { - type: "boolean", - control: "boolean", - required: false, - description: attribute.description, - }; + return { type: "boolean", control: "boolean", required, description }; } attribute.type satisfies never; throw Error("impossible case"); @@ -509,3 +499,36 @@ export const $selectedInstancePropsMetas = computed( return new Map(Array.from(metas.entries()).reverse()); } ); + +export const $selectedInstanceInitialPropNames = computed( + [ + $selectedInstance, + $registeredComponentPropsMetas, + $selectedInstancePropsMetas, + ], + (selectedInstance, componentPropsMetas, instancePropsMetas) => { + const initialPropNames = new Set(); + if (selectedInstance) { + const initialProps = + componentPropsMetas.get(selectedInstance.component)?.initialProps ?? []; + for (const propName of initialProps) { + // className -> class + if (instancePropsMetas.has(reactPropsToStandardAttributes[propName])) { + initialPropNames.add(reactPropsToStandardAttributes[propName]); + } else { + initialPropNames.add(propName); + } + } + } + for (const [propName, propMeta] of instancePropsMetas) { + // skip show attribute which is added as system prop + if (propName === showAttribute) { + continue; + } + if (propMeta.required) { + initialPropNames.add(propName); + } + } + return initialPropNames; + } +); diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts index ea616a027baf..ea158f28197c 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -26,7 +26,11 @@ const spaceRegex = /^\s*$/; const getAttributeType = ( attribute: (typeof ariaAttributes)[number] ): "string" | "boolean" | "number" => { - if (attribute.type === "string" || attribute.type === "select") { + if ( + attribute.type === "string" || + attribute.type === "select" || + attribute.type === "url" + ) { return "string"; } if (attribute.type === "number" || attribute.type === "boolean") { diff --git a/apps/builder/app/shared/nano-states/components.ts b/apps/builder/app/shared/nano-states/components.ts index 4f31199dac87..8e41229ad47b 100644 --- a/apps/builder/app/shared/nano-states/components.ts +++ b/apps/builder/app/shared/nano-states/components.ts @@ -233,18 +233,7 @@ export const registerComponentLibrary = ({ const prevPropsMetas = $registeredComponentPropsMetas.get(); const nextPropsMetas = new Map(prevPropsMetas); for (const [componentName, propsMeta] of Object.entries(propsMetas)) { - const { initialProps = [], props } = propsMeta; - const requiredProps: string[] = []; - for (const [name, value] of Object.entries(props)) { - if (value.required && initialProps.includes(name) === false) { - requiredProps.push(name); - } - } - nextPropsMetas.set(`${prefix}${componentName}`, { - // order of initialProps must be preserved - initialProps: [...initialProps, ...requiredProps], - props, - }); + nextPropsMetas.set(`${prefix}${componentName}`, propsMeta); } $registeredComponentPropsMetas.set(nextPropsMetas); }; diff --git a/packages/html-data/bin/aria.ts b/packages/html-data/bin/aria.ts index 69086723cdd3..1e1a382ab9e8 100644 --- a/packages/html-data/bin/aria.ts +++ b/packages/html-data/bin/aria.ts @@ -79,7 +79,8 @@ for (const [name, meta] of aria.entries()) { const ariaContent = `type Attribute = { name: string, description: string, - type: 'string' | 'boolean' | 'number' | 'select', + required?: boolean, + type: 'string' | 'boolean' | 'number' | 'select' | 'url', options?: string[] } diff --git a/packages/html-data/bin/attributes.ts b/packages/html-data/bin/attributes.ts index 0affab698464..58f581615141 100644 --- a/packages/html-data/bin/attributes.ts +++ b/packages/html-data/bin/attributes.ts @@ -22,7 +22,8 @@ const validHtmlAttributes = new Set(); type Attribute = { name: string; description: string; - type: "string" | "boolean" | "number" | "select"; + required?: boolean; + type: "string" | "boolean" | "number" | "select" | "url"; options?: string[]; }; @@ -48,10 +49,21 @@ const overrides: Record< options: undefined, }, }, + a: { + href: { type: "url", required: true }, + target: { required: true }, + download: { type: "boolean", required: true }, + }, + form: { + action: { required: true }, + method: { required: true }, + enctype: { required: true }, + }, area: { ping: false, }, button: { + type: { required: true }, command: false, commandfor: false, popovertarget: false, @@ -95,10 +107,13 @@ for (const row of rows) { if (value.endsWith(";")) { value = value.slice(0, -1); } - const possibleOptions = value + let possibleOptions = value .split(/\s*;\s*/) .filter((item) => item.startsWith('"') && item.endsWith('"')) .map((item) => item.slice(1, -1)); + if (value.includes("valid navigable target name or keyword")) { + possibleOptions = ["_blank", "_self", "_parent", "_top"]; + } let type: "string" | "boolean" | "number" | "select" = "string"; let options: undefined | string[]; if (possibleOptions.length > 0) { @@ -156,7 +171,8 @@ for (const tag of tags) { const attributesContent = `type Attribute = { name: string, description: string, - type: 'string' | 'boolean' | 'number' | 'select', + required?: boolean, + type: 'string' | 'boolean' | 'number' | 'select' | 'url', options?: string[] } @@ -204,8 +220,8 @@ for (const entry of Object.entries(attributesByTag)) { for (const { name, type, options } of attributes) { const id = getId(); const instanceId = instance.id; - if (type === "string") { - const prop: Prop = { id, instanceId, type, name, value: "" }; + if (type === "string" || type === "url") { + const prop: Prop = { id, instanceId, type: "string", name, value: "" }; props.set(prop.id, prop); continue; } @@ -236,6 +252,7 @@ for (const entry of Object.entries(attributesByTag)) { props.set(prop.id, prop); continue; } + (type) satisfies never; throw Error(`Unknown attribute ${name} with type ${type}`); } } diff --git a/packages/html-data/src/__generated__/aria.ts b/packages/html-data/src/__generated__/aria.ts index a940a38b1763..1587a31cb225 100644 --- a/packages/html-data/src/__generated__/aria.ts +++ b/packages/html-data/src/__generated__/aria.ts @@ -1,7 +1,8 @@ type Attribute = { name: string; description: string; - type: "string" | "boolean" | "number" | "select"; + required?: boolean; + type: "string" | "boolean" | "number" | "select" | "url"; options?: string[]; }; diff --git a/packages/html-data/src/__generated__/attributes-jsx-test.tsx b/packages/html-data/src/__generated__/attributes-jsx-test.tsx index c86a5c0e5438..39e234c2a602 100644 --- a/packages/html-data/src/__generated__/attributes-jsx-test.tsx +++ b/packages/html-data/src/__generated__/attributes-jsx-test.tsx @@ -28,13 +28,13 @@ const Page = () => { className={`${""}`} /> @@ -46,7 +46,7 @@ const Page = () => { referrerPolicy={""} rel={""} shape={"circle"} - target={""} + target={"_blank"} />