Skip to content

Commit 93c0057

Browse files
authored
perf: reuse aria attributes between all components (#5160)
Ref #3632 We have a universally generated list of aria attributes and can reuse it instead of generating for every component and bloating our client bundle. Now users will have to download ~700kB less javascript when load builder. ```diff - 5748 build/client/assets + 5036 build/client/assets ``` <img width="299" alt="Screenshot 2025-04-24 at 12 01 46" src="https://github.com/user-attachments/assets/1faedc01-8f5f-47ad-8601-d17bf5059ea3" />
1 parent 3f208b5 commit 93c0057

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+1161
-30337
lines changed

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

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import { computed } from "nanostores";
1+
import { nanoid } from "nanoid";
22
import { useState } from "react";
3+
import { computed } from "nanostores";
34
import { useStore } from "@nanostores/react";
45
import { matchSorter } from "match-sorter";
5-
import { type Instance, Props, descendantComponent } from "@webstudio-is/sdk";
6+
import { ariaAttributes } from "@webstudio-is/html-data";
7+
import {
8+
type Instance,
9+
type Props,
10+
descendantComponent,
11+
} from "@webstudio-is/sdk";
612
import {
713
theme,
814
Combobox,
@@ -20,15 +26,18 @@ import {
2026
$isContentMode,
2127
$memoryProps,
2228
$selectedBreakpoint,
29+
$registeredComponentPropsMetas,
2330
} from "~/shared/nano-states";
2431
import { CollapsibleSectionWithAddButton } from "~/builder/shared/collapsible-section";
32+
import { serverSyncStore } from "~/shared/sync";
33+
import { $selectedInstance, $selectedInstanceKey } from "~/shared/awareness";
2534
import { renderControl } from "../controls/combined";
2635
import { usePropsLogic, type PropAndMeta } from "./use-props-logic";
27-
import { serverSyncStore } from "~/shared/sync";
28-
import { $selectedInstanceKey } from "~/shared/awareness";
2936
import { AnimationSection } from "./animation/animation-section";
30-
import { nanoid } from "nanoid";
31-
import { $matchingBreakpoints } from "../../style-panel/shared/model";
37+
import {
38+
$instanceTags,
39+
$matchingBreakpoints,
40+
} from "../../style-panel/shared/model";
3241
import { matchMediaBreakpoints } from "./match-media-breakpoints";
3342

3443
type Item = {
@@ -44,14 +53,15 @@ const matchOrSuggestToCreate = (
4453
items: Array<Item>,
4554
itemToString: (item: Item) => string
4655
): Array<Item> => {
56+
if (search.trim() === "") {
57+
return items;
58+
}
4759
const matched = matchSorter(items, search, {
4860
keys: [itemToString],
4961
});
50-
5162
if (
52-
search.trim() !== "" &&
5363
itemToString(matched[0]).toLocaleLowerCase() !==
54-
search.toLocaleLowerCase().trim()
64+
search.toLocaleLowerCase().trim()
5565
) {
5666
matched.unshift({
5767
name: search.trim(),
@@ -88,11 +98,43 @@ const renderProperty = (
8898

8999
const forbiddenProperties = new Set(["style", "class", "className"]);
90100

101+
const $availableProps = computed(
102+
[$selectedInstance, $props, $registeredComponentPropsMetas, $instanceTags],
103+
(instance, props, propsMetas, instanceTags) => {
104+
const availableProps = new Map<Item["name"], Item>();
105+
if (instance === undefined) {
106+
return [];
107+
}
108+
// add component props
109+
const metas = propsMetas.get(instance.component);
110+
for (const [name, propMeta] of Object.entries(metas?.props ?? {})) {
111+
const { label, description } = propMeta;
112+
availableProps.set(name, { name, label, description });
113+
}
114+
// add aria attributes only for components with tags
115+
const tag = instanceTags.get(instance.id);
116+
if (tag) {
117+
for (const { name, description } of ariaAttributes) {
118+
availableProps.set(name, { name, description });
119+
}
120+
}
121+
// remove initial props
122+
for (const name of metas?.initialProps ?? []) {
123+
availableProps.delete(name);
124+
}
125+
// remove defined props
126+
for (const prop of props.values()) {
127+
if (prop.instanceId === instance.id) {
128+
availableProps.delete(prop.name);
129+
}
130+
}
131+
return Array.from(availableProps.values());
132+
}
133+
);
134+
91135
const AddPropertyOrAttribute = ({
92-
availableProps,
93136
onPropSelected,
94137
}: {
95-
availableProps: Item[];
96138
onPropSelected: (propName: string) => void;
97139
}) => {
98140
const [value, setValue] = useState("");
@@ -108,7 +150,8 @@ const AddPropertyOrAttribute = ({
108150
autoFocus
109151
color={isValid ? undefined : "error"}
110152
placeholder="Select or create"
111-
getItems={() => availableProps}
153+
// lazily load available props to not bloat component renders
154+
getItems={() => $availableProps.get()}
112155
itemToString={itemToString}
113156
onItemSelect={(item) => {
114157
if (
@@ -241,7 +284,6 @@ export const PropsSection = (props: PropsSectionProps) => {
241284
<Flex gap="1" direction="column">
242285
{addingProp && (
243286
<AddPropertyOrAttribute
244-
availableProps={logic.availableProps}
245287
onPropSelected={(propName) => {
246288
setAddingProp(false);
247289
logic.handleAdd(propName);

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

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { isRichText } from "~/shared/content-model";
1515
import { $selectedInstancePath } from "~/shared/awareness";
1616
import { showAttributeMeta, type PropValue } from "../shared";
17+
import { ariaAttributes } from "@webstudio-is/html-data";
1718

1819
type PropOrName = { prop?: Prop; propName: string };
1920

@@ -153,6 +154,42 @@ const $canHaveTextContent = computed(
153154
});
154155
}
155156
);
157+
type Attribute = (typeof ariaAttributes)[number];
158+
159+
const attributeToMeta = (attribute: Attribute): PropMeta => {
160+
if (attribute.type === "string") {
161+
return {
162+
type: "string",
163+
control: "text",
164+
required: false,
165+
};
166+
}
167+
if (attribute.type === "select") {
168+
const options = attribute.options ?? [];
169+
return {
170+
type: "string",
171+
control: options.length > 3 ? "select" : "radio",
172+
required: false,
173+
options,
174+
};
175+
}
176+
if (attribute.type === "number") {
177+
return {
178+
type: "number",
179+
control: "number",
180+
required: false,
181+
};
182+
}
183+
if (attribute.type === "boolean") {
184+
return {
185+
type: "boolean",
186+
control: "boolean",
187+
required: false,
188+
};
189+
}
190+
attribute.type satisfies never;
191+
throw Error("impossible case");
192+
};
156193

157194
/** usePropsLogic expects that key={instanceId} is used on the ancestor component */
158195
export const usePropsLogic = ({
@@ -193,9 +230,14 @@ export const usePropsLogic = ({
193230

194231
// we will delete items from these maps as we categorize the props
195232
const unprocessedSaved = new Map(savedProps.map((prop) => [prop.name, prop]));
196-
const unprocessedKnown = new Map<Prop["name"], PropMeta>(
197-
Object.entries(meta.props)
198-
);
233+
234+
const metas = new Map<Prop["name"], PropMeta>();
235+
for (const attribute of ariaAttributes) {
236+
metas.set(attribute.name, attributeToMeta(attribute));
237+
}
238+
for (const [name, propMeta] of Object.entries(meta.props)) {
239+
metas.set(name, propMeta);
240+
}
199241

200242
const initialPropsNames = new Set(meta.initialProps ?? []);
201243

@@ -238,9 +280,9 @@ export const usePropsLogic = ({
238280
const initialProps: PropAndMeta[] = [];
239281
for (const name of initialPropsNames) {
240282
const saved = getAndDelete<Prop>(unprocessedSaved, name);
241-
const known = getAndDelete(unprocessedKnown, name);
283+
const propMeta = metas.get(name);
242284

243-
if (known === undefined) {
285+
if (propMeta === undefined) {
244286
console.error(
245287
`The prop "${name}" is defined in meta.initialProps but not in meta.props`
246288
);
@@ -258,14 +300,14 @@ export const usePropsLogic = ({
258300
// - where 0 is a fallback when no default is available
259301
// - they think that width is set to 0, but it's actually not set at all
260302
//
261-
if (prop === undefined && known.defaultValue !== undefined) {
262-
prop = getStartingProp(instance.id, known, name);
303+
if (prop === undefined && propMeta.defaultValue !== undefined) {
304+
prop = getStartingProp(instance.id, propMeta, name);
263305
}
264306

265307
initialProps.push({
266308
prop,
267309
propName: name,
268-
meta: known,
310+
meta: propMeta,
269311
});
270312
}
271313

@@ -276,22 +318,18 @@ export const usePropsLogic = ({
276318
continue;
277319
}
278320

279-
const meta =
280-
getAndDelete(unprocessedKnown, prop.name) ??
281-
getDefaultMetaForType("string");
321+
const propMeta = metas.get(prop.name) ?? getDefaultMetaForType("string");
282322

283323
addedProps.push({
284324
prop,
285325
propName: prop.name,
286-
meta,
326+
meta: propMeta,
287327
});
288328
}
289329

290330
const handleAdd = (propName: string) => {
291-
const propMeta =
292-
unprocessedKnown.get(propName) ??
293-
// In case of custom property/attribute we get a string.
294-
getDefaultMetaForType("string");
331+
// In case of custom property/attribute we get a string.
332+
const propMeta = metas.get(propName) ?? getDefaultMetaForType("string");
295333
const prop = getStartingProp(instance.id, propMeta, propName);
296334
if (prop) {
297335
updateProp(prop);
@@ -330,10 +368,5 @@ export const usePropsLogic = ({
330368
),
331369
/** Optional props that were added by user */
332370
addedProps: addedProps.filter(({ propName }) => isPropVisible(propName)),
333-
/** List of remaining props still available to add */
334-
availableProps: Array.from(
335-
unprocessedKnown.entries(),
336-
([name, { label, description }]) => ({ name, label, description })
337-
),
338371
};
339372
};

packages/generate-arg-types/src/arg-types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ export const propsToArgTypes = (
1919
.filter(([propName]) => propName.startsWith("$") === false)
2020
// Exclude props that are in the exclude list
2121
.filter(([propName]) => exclude.includes(propName) === false)
22+
.filter(([_propName, propItem]) => {
23+
for (const { fileName, name } of propItem.declarations ?? []) {
24+
// ignore aria attributes
25+
if (fileName.endsWith("/@types/react/index.d.ts")) {
26+
return name !== "AriaAttributes";
27+
}
28+
}
29+
return true;
30+
})
2231
.map(([propName, propItem]) => {
2332
// Remove @see and @deprecated from description also {@link ...} is removed as it always go after @see
2433
propItem.description = propItem.description

packages/generate-arg-types/src/cli.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ type CustomDescriptionsType = {
9191
}
9292
}
9393

94-
const generatedFile = `${path.basename(filePath, ".tsx")}.props.ts`;
94+
const basename = path.basename(filePath, ".tsx");
95+
const generatedFile = `${basename}.props.ts`;
9596
const generatedPath = path.join(generatedDir, generatedFile);
9697

9798
const componentDocs = tsConfigParser.parse(filePath);

packages/html-data/bin/aria.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ type Attribute = {
2424
options?: string[];
2525
};
2626

27+
const overrides: Record<string, Partial<Attribute>> = {
28+
"aria-label": {
29+
description:
30+
"Provides the accessible name that describes an interactive element if no other accessible name exists, for example in a button that contains an image with no text.",
31+
},
32+
};
33+
2734
const html = await loadPage("aria1.3", "https://www.w3.org/TR/wai-aria-1.3");
2835
const document = parseHtml(html);
2936
const list = findTags(document, "dl").find(
@@ -44,6 +51,7 @@ for (const [name, meta] of aria.entries()) {
4451
name,
4552
description: descriptions.get(name) ?? "",
4653
type: "string",
54+
...overrides[name],
4755
};
4856
if (meta.type === "string" || meta.type === "boolean") {
4957
attribute.type = meta.type;

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

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

packages/html-data/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from "./__generated__/content-model";
22
export * from "./descriptions";
33
export * from "./dom-attributes-react-mappings";
44
export * from "./__generated__/attributes";
5+
export * from "./__generated__/aria";

packages/sdk-components-animation/src/__generated__/animate-text.props.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sdk-components-animation/src/__generated__/stagger-animation.props.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)