diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/advanced.tsx b/apps/builder/app/builder/features/style-panel/sections/advanced/advanced.tsx index 14b67fe0e876..6534e6b099b8 100644 --- a/apps/builder/app/builder/features/style-panel/sections/advanced/advanced.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/advanced.tsx @@ -1,11 +1,12 @@ import { lexer } from "css-tree"; import { colord } from "colord"; import { + forwardRef, memo, useEffect, - useMemo, useRef, useState, + type ComponentProps, type ReactNode, } from "react"; import { useStore } from "@nanostores/react"; @@ -28,6 +29,7 @@ import { SectionTitle, SectionTitleButton, SectionTitleLabel, + Separator, Text, theme, Tooltip, @@ -73,6 +75,7 @@ import { import { useClientSupports } from "~/shared/client-supports"; import { $selectedInstancePath } from "~/shared/awareness"; import { $settings } from "~/builder/shared/client-settings"; +import { composeEventHandlers } from "~/shared/event-utils"; // Only here to keep the same section module interface export const properties = []; @@ -91,6 +94,7 @@ const AdvancedStyleSection = (props: { label={label} isOpen={isOpen} onOpenChange={setIsOpen} + fullWidth trigger={ { const insertStyles = (text: string) => { const parsedStyles = parseCss(`selector{${text}}`); if (parsedStyles.length === 0) { - return false; + return []; } const batch = createBatchUpdate(); for (const { property, value } of parsedStyles) { batch.setProperty(property)(value); } batch.publish({ listed: true }); - return true; + return parsedStyles; }; +const sortedProperties = Object.keys(propertiesData) + .sort(Intl.Collator().compare) + .map((property) => ({ + value: property, + label: hyphenateProperty(property), + })); + /** * * Advanced search control supports following interactions @@ -167,67 +178,74 @@ const insertStyles = (text: string) => { * paste css declarations * */ -const AdvancedSearch = ({ - usedProperties, - onSelect, - onClose, -}: { - usedProperties: string[]; - onSelect: (value: StyleProperty) => void; - onClose: () => void; -}) => { - const availableProperties = useMemo(() => { - const properties = Object.keys(propertiesData).sort( - Intl.Collator().compare - ) as StyleProperty[]; - const availableProperties: SearchItem[] = []; - for (const property of properties) { - if (usedProperties.includes(property) === false) { - availableProperties.push({ - value: property, - label: hyphenateProperty(property), - }); - } - } - return availableProperties; - }, [usedProperties]); +const AddProperty = forwardRef< + HTMLInputElement, + { + onSelect: (value: StyleProperty) => void; + onClose: () => void; + onSubmit: (value: string) => void; + onFocus: () => void; + } +>(({ onSelect, onClose, onSubmit, onFocus }, forwardedRef) => { const [item, setItem] = useState({ value: "", label: "", }); const combobox = useCombobox({ - getItems: () => availableProperties, + getItems: () => sortedProperties, itemToString: (item) => item?.label ?? "", value: item, defaultHighlightedIndex: 0, getItemProps: () => ({ text: "sentence" }), match: matchOrSuggestToCreate, onChange: (value) => setItem({ value: value ?? "", label: value ?? "" }), - onItemSelect: (item) => onSelect(item.value as StyleProperty), + onItemSelect: (item) => { + clear(); + onSelect(item.value as StyleProperty); + }, }); const descriptionItem = combobox.items[combobox.highlightedIndex]; const description = getNewPropertyDescription(descriptionItem); const descriptions = combobox.items.map(getNewPropertyDescription); + const inputProps = combobox.getInputProps(); + + const clear = () => { + setItem({ value: "", label: "" }); + }; + + const handleKeys = (event: KeyboardEvent) => { + // Dropdown might handle enter or escape. + if (event.defaultPrevented) { + return; + } + if (event.key === "Enter") { + clear(); + onSubmit(item.value); + return; + } + if (event.key === "Escape") { + clear(); + onClose(); + } + }; + + const handleKeyDown = composeEventHandlers(inputProps.onKeyDown, handleKeys, { + // Pass prevented events to the combobox (e.g., the Escape key doesn't work otherwise, as it's blocked by Radix) + checkForDefaultPrevented: false, + }); return ( -
{ - event.preventDefault(); - const isInserted = insertStyles(item.value); - if (isInserted) { - onClose(); - } - }} - > +
} /> @@ -251,12 +269,18 @@ const AdvancedSearch = ({ )} - +
); -}; +}); -const AdvancedPropertyLabel = ({ property }: { property: StyleProperty }) => { +const AdvancedPropertyLabel = ({ + property, + onReset, +}: { + property: StyleProperty; + onReset?: () => void; +}) => { const styleDecl = useComputedStyleDecl(property); const label = hyphenateProperty(property); const description = propertyDescriptions[property]; @@ -272,6 +296,7 @@ const AdvancedPropertyLabel = ({ property }: { property: StyleProperty }) => { if (event.altKey) { event.preventDefault(); deleteProperty(property); + onReset?.(); return; } setIsOpen(true); @@ -285,6 +310,7 @@ const AdvancedPropertyLabel = ({ property }: { property: StyleProperty }) => { onReset={() => { deleteProperty(property); setIsOpen(false); + onReset?.(); }} /> } @@ -305,9 +331,13 @@ const AdvancedPropertyLabel = ({ property }: { property: StyleProperty }) => { const AdvancedPropertyValue = ({ autoFocus, property, + onChangeComplete, }: { autoFocus?: boolean; property: StyleProperty; + onChangeComplete: ComponentProps< + typeof CssValueInputContainer + >["onChangeComplete"]; }) => { const styleDecl = useComputedStyleDecl(property); const inputRef = useRef(null); @@ -365,6 +395,7 @@ const AdvancedPropertyValue = ({ } }} deleteProperty={deleteProperty} + onChangeComplete={onChangeComplete} /> ); }; @@ -415,14 +446,12 @@ const $advancedProperties = computed( } const advancedProperties = new Set(); for (const { property, listed } of definedStyles) { - // In advanced mode we show all defined properties - if (settings.stylePanelMode === "advanced") { - advancedProperties.add(property); - continue; - } - // exclude properties from style panel UI unless edited in advanced section - if (baseProperties.has(property) === false || listed) { - advancedProperties.add(property); + if (baseProperties.has(property) === false) { + // When property is listed, it was added from advanced panel. + // If we are in advanced mode, we show them all. + if (listed || settings.stylePanelMode === "advanced") { + advancedProperties.add(property); + } } } // In advanced mode we assume user knows the properties they need, so we don't need to show these. @@ -445,9 +474,15 @@ const AdvancedProperty = memo( ({ property, autoFocus, + onChangeComplete, + onReset, }: { property: StyleProperty; - autoFocus: boolean; + autoFocus?: boolean; + onReset?: () => void; + onChangeComplete?: ComponentProps< + typeof CssValueInputContainer + >["onChangeComplete"]; }) => { const visibilityChangeEventSupported = useClientSupports( () => "oncontentvisibilityautostatechange" in document.body @@ -501,9 +536,13 @@ const AdvancedProperty = memo( > {isVisible && ( <> - + : - + )} @@ -514,36 +553,92 @@ const AdvancedProperty = memo( export const Section = () => { const [isAdding, setIsAdding] = useState(false); const advancedProperties = useStore($advancedProperties); - const newlyAddedProperty = useRef(undefined); + const [recentProperties, setRecentProperties] = useState([]); + const addPropertyInputRef = useRef(null); + + const addRecentProperties = (properties: StyleProperty[]) => { + setRecentProperties( + Array.from(new Set([...recentProperties, ...properties])) + ); + }; + + const showAddProperty = () => { + setIsAdding(true); + // User can click twice on the add button, so we need to focus the input on the second click after autoFocus isn't working. + addPropertyInputRef.current?.focus(); + }; return ( setIsAdding(true)} + onAdd={showAddProperty} > - {isAdding && ( - { - newlyAddedProperty.current = property; - setIsAdding(false); - setProperty(property)( - { type: "guaranteedInvalid" }, - { listed: true } - ); - }} - onClose={() => setIsAdding(false)} - /> - )} - - {advancedProperties.map((property) => ( + + {recentProperties.map((property, index, properties) => ( { + if (event.type === "enter") { + showAddProperty(); + } + }} + onReset={() => { + setRecentProperties((properties) => { + return properties.filter( + (recentProperty) => recentProperty !== property + ); + }); + }} /> ))} + + { + setIsAdding(false); + const isNew = advancedProperties.includes(property) === false; + if (isNew) { + setProperty(property)( + { type: "guaranteedInvalid" }, + { listed: true } + ); + } + addRecentProperties([property]); + }} + onSubmit={(value) => { + setIsAdding(false); + const styles = insertStyles(value); + const insertedProperties = styles.map(({ property }) => property); + addRecentProperties(insertedProperties); + }} + onClose={() => { + setIsAdding(false); + }} + onFocus={() => { + if (isAdding === false) { + showAddProperty(); + } + }} + ref={addPropertyInputRef} + /> + + + {recentProperties.length > 0 && } + + {advancedProperties + .filter((property) => recentProperties.includes(property) === false) + .map((property) => ( + + ))} ); diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input-container.tsx b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input-container.tsx index d03723b67c36..3d39d69a5a9f 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input-container.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input-container.tsx @@ -10,16 +10,19 @@ type CssValueInputContainerProps = { ComponentProps, | "onChange" | "onHighlight" - | "onChangeComplete" | "onReset" | "onAbort" | "intermediateValue" ->; + | "onChangeComplete" +> & { + onChangeComplete?: ComponentProps["onChangeComplete"]; + }; export const CssValueInputContainer = ({ property, setValue, deleteProperty, + onChangeComplete, ...props }: CssValueInputContainerProps) => { const [intermediateValue, setIntermediateValue] = useState< @@ -50,9 +53,10 @@ export const CssValueInputContainer = ({ deleteProperty(property, { isEphemeral: true }); } }} - onChangeComplete={({ value }) => { - setValue(value); + onChangeComplete={(event) => { + setValue(event.value); setIntermediateValue(undefined); + onChangeComplete?.(event); }} onAbort={() => { deleteProperty(property, { isEphemeral: true }); diff --git a/apps/builder/app/builder/features/style-panel/style-panel.tsx b/apps/builder/app/builder/features/style-panel/style-panel.tsx index 605aa8ccb0c5..bdf142ddf8f9 100644 --- a/apps/builder/app/builder/features/style-panel/style-panel.tsx +++ b/apps/builder/app/builder/features/style-panel/style-panel.tsx @@ -81,7 +81,8 @@ export const ModeMenu = () => { onFocus={() => setFocusedValue("focus")} > - Focus mode + Focus mode + {isFeatureEnabled("stylePanelAdvancedMode") && (