Skip to content

Commit 769b246

Browse files
authored
feat: Context menu for advanced style panel (#4876)
#4816 ## Description 1. right click in advanced 2. copy all 3. copy one 4. paste 5. search now works for properties AND values 6. backspace in add styles input when its empty now also closes the input same as esc ## Steps for reproduction 1. click button 7. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
1 parent ecb23c0 commit 769b246

File tree

9 files changed

+713
-388
lines changed

9 files changed

+713
-388
lines changed
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { lexer } from "css-tree";
2+
import { forwardRef, useRef, useState, type KeyboardEvent } from "react";
3+
import { matchSorter } from "match-sorter";
4+
import {
5+
Box,
6+
ComboboxAnchor,
7+
ComboboxContent,
8+
ComboboxItemDescription,
9+
ComboboxListbox,
10+
ComboboxListboxItem,
11+
ComboboxRoot,
12+
ComboboxScrollArea,
13+
InputField,
14+
NestedInputButton,
15+
Text,
16+
theme,
17+
useCombobox,
18+
} from "@webstudio-is/design-system";
19+
import {
20+
properties as propertiesData,
21+
keywordValues,
22+
propertyDescriptions,
23+
parseCssValue,
24+
} from "@webstudio-is/css-data";
25+
import {
26+
cssWideKeywords,
27+
hyphenateProperty,
28+
type StyleProperty,
29+
} from "@webstudio-is/css-engine";
30+
import { deleteProperty, setProperty } from "../../shared/use-style-data";
31+
import { composeEventHandlers } from "~/shared/event-utils";
32+
33+
type SearchItem = { property: string; label: string; value?: string };
34+
35+
const autoCompleteItems: Array<SearchItem> = [];
36+
37+
const getNewPropertyDescription = (item: null | SearchItem) => {
38+
let description: string | undefined = `Create CSS variable.`;
39+
if (item && item.property in propertyDescriptions) {
40+
description = propertyDescriptions[item.property];
41+
}
42+
return <Box css={{ width: theme.spacing[28] }}>{description}</Box>;
43+
};
44+
45+
const getAutocompleteItems = () => {
46+
if (autoCompleteItems.length > 0) {
47+
return autoCompleteItems;
48+
}
49+
for (const property in propertiesData) {
50+
autoCompleteItems.push({
51+
property,
52+
label: hyphenateProperty(property),
53+
});
54+
}
55+
56+
const ignoreValues = new Set([...cssWideKeywords, ...keywordValues.color]);
57+
58+
for (const property in keywordValues) {
59+
const values = keywordValues[property as keyof typeof keywordValues];
60+
for (const value of values) {
61+
if (ignoreValues.has(value)) {
62+
continue;
63+
}
64+
autoCompleteItems.push({
65+
property,
66+
value,
67+
label: `${hyphenateProperty(property)}: ${value}`,
68+
});
69+
}
70+
}
71+
72+
autoCompleteItems.sort((a, b) =>
73+
Intl.Collator().compare(a.property, b.property)
74+
);
75+
76+
return autoCompleteItems;
77+
};
78+
79+
const matchOrSuggestToCreate = (
80+
search: string,
81+
items: Array<SearchItem>,
82+
itemToString: (item: SearchItem) => string
83+
) => {
84+
const matched = matchSorter(items, search, {
85+
keys: [itemToString],
86+
});
87+
88+
// Limit the array to 100 elements
89+
matched.length = Math.min(matched.length, 100);
90+
91+
const property = search.trim();
92+
if (
93+
property.startsWith("--") &&
94+
lexer.match("<custom-ident>", property).matched
95+
) {
96+
matched.unshift({
97+
property,
98+
label: `Create "${property}"`,
99+
});
100+
}
101+
// When there is no match we suggest to create a custom property.
102+
if (
103+
matched.length === 0 &&
104+
lexer.match("<custom-ident>", `--${property}`).matched
105+
) {
106+
matched.unshift({
107+
property: `--${property}`,
108+
label: `--${property}: unset;`,
109+
});
110+
}
111+
112+
return matched;
113+
};
114+
115+
/**
116+
*
117+
* Advanced search control supports following interactions
118+
*
119+
* find property
120+
* create custom property
121+
* submit css declarations
122+
* paste css declarations
123+
*
124+
*/
125+
export const AddStylesInput = forwardRef<
126+
HTMLInputElement,
127+
{
128+
onClose: () => void;
129+
onSubmit: (css: string) => void;
130+
onFocus: () => void;
131+
onBlur: () => void;
132+
}
133+
>(({ onClose, onSubmit, onFocus, onBlur }, forwardedRef) => {
134+
const [item, setItem] = useState<SearchItem>({
135+
property: "",
136+
label: "",
137+
});
138+
const highlightedItemRef = useRef<SearchItem>();
139+
140+
const combobox = useCombobox<SearchItem>({
141+
getItems: getAutocompleteItems,
142+
itemToString: (item) => item?.label ?? "",
143+
value: item,
144+
defaultHighlightedIndex: 0,
145+
getItemProps: () => ({ text: "sentence" }),
146+
match: matchOrSuggestToCreate,
147+
onChange: (value) => setItem({ property: value ?? "", label: value ?? "" }),
148+
onItemSelect: (item) => {
149+
clear();
150+
onSubmit(`${item.property}: ${item.value ?? "unset"}`);
151+
},
152+
onItemHighlight: (item) => {
153+
const previousHighlightedItem = highlightedItemRef.current;
154+
if (item?.value === undefined && previousHighlightedItem) {
155+
deleteProperty(previousHighlightedItem.property as StyleProperty, {
156+
isEphemeral: true,
157+
});
158+
highlightedItemRef.current = undefined;
159+
return;
160+
}
161+
162+
if (item?.value) {
163+
const value = parseCssValue(item.property as StyleProperty, item.value);
164+
setProperty(item.property as StyleProperty)(value, {
165+
isEphemeral: true,
166+
});
167+
highlightedItemRef.current = item;
168+
}
169+
},
170+
});
171+
172+
const descriptionItem = combobox.items[combobox.highlightedIndex];
173+
const description = getNewPropertyDescription(descriptionItem);
174+
const descriptions = combobox.items.map(getNewPropertyDescription);
175+
const inputProps = combobox.getInputProps();
176+
177+
const clear = () => {
178+
setItem({ property: "", label: "" });
179+
};
180+
181+
const handleKeys = (event: KeyboardEvent) => {
182+
// Dropdown might handle enter or escape.
183+
if (event.defaultPrevented) {
184+
return;
185+
}
186+
if (event.key === "Enter") {
187+
clear();
188+
onSubmit(item.property);
189+
return;
190+
}
191+
// When user hits backspace and there is nothing in the input - we hide the input
192+
const abortByBackspace =
193+
event.key === "Backspace" && combobox.inputValue === "";
194+
195+
if (event.key === "Escape" || abortByBackspace) {
196+
clear();
197+
onClose();
198+
event.preventDefault();
199+
}
200+
};
201+
202+
const handleKeyDown = composeEventHandlers(inputProps.onKeyDown, handleKeys, {
203+
// Pass prevented events to the combobox (e.g., the Escape key doesn't work otherwise, as it's blocked by Radix)
204+
checkForDefaultPrevented: false,
205+
});
206+
207+
return (
208+
<ComboboxRoot open={combobox.isOpen}>
209+
<div {...combobox.getComboboxProps()}>
210+
<ComboboxAnchor>
211+
<InputField
212+
{...inputProps}
213+
autoFocus
214+
onFocus={onFocus}
215+
onBlur={(event) => {
216+
inputProps.onBlur(event);
217+
onBlur();
218+
}}
219+
inputRef={forwardedRef}
220+
onKeyDown={handleKeyDown}
221+
placeholder="Add styles"
222+
suffix={<NestedInputButton {...combobox.getToggleButtonProps()} />}
223+
/>
224+
</ComboboxAnchor>
225+
<ComboboxContent>
226+
<ComboboxListbox {...combobox.getMenuProps()}>
227+
<ComboboxScrollArea>
228+
{combobox.items.map((item, index) => (
229+
<ComboboxListboxItem
230+
{...combobox.getItemProps({ item, index })}
231+
key={index}
232+
asChild
233+
>
234+
<Text
235+
variant="labelsSentenceCase"
236+
truncate
237+
css={{ maxWidth: "25ch" }}
238+
>
239+
{item.label}
240+
</Text>
241+
</ComboboxListboxItem>
242+
))}
243+
</ComboboxScrollArea>
244+
{description && (
245+
<ComboboxItemDescription descriptions={descriptions}>
246+
{description}
247+
</ComboboxItemDescription>
248+
)}
249+
</ComboboxListbox>
250+
</ComboboxContent>
251+
</div>
252+
</ComboboxRoot>
253+
);
254+
});

0 commit comments

Comments
 (0)