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
24 changes: 10 additions & 14 deletions apps/builder/app/builder/features/settings-panel/property-label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
<Flex align="center" css={{ gap: theme.spacing[3] }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -107,22 +109,20 @@ const $availableProps = computed(
[
$selectedInstance,
$props,
$registeredComponentPropsMetas,
$selectedInstancePropsMetas,
$selectedInstanceInitialPropNames,
],
(instance, props, componentPropsMetas, instancePropsMetas) => {
(instance, props, propsMetas, initialPropNames) => {
const availableProps = new Map<Item["name"], Item>();
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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
67 changes: 45 additions & 22 deletions apps/builder/app/builder/features/settings-panel/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<string>();
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;
}
);
6 changes: 5 additions & 1 deletion apps/builder/app/shared/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
13 changes: 1 addition & 12 deletions apps/builder/app/shared/nano-states/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
3 changes: 2 additions & 1 deletion packages/html-data/bin/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}

Expand Down
27 changes: 22 additions & 5 deletions packages/html-data/bin/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const validHtmlAttributes = new Set<string>();
type Attribute = {
name: string;
description: string;
type: "string" | "boolean" | "number" | "select";
required?: boolean;
type: "string" | "boolean" | "number" | "select" | "url";
options?: string[];
};

Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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[]
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}`);
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/html-data/src/__generated__/aria.ts

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

Loading