diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/add-styles-input.tsx b/apps/builder/app/builder/features/style-panel/sections/advanced/add-styles-input.tsx new file mode 100644 index 000000000000..c5c001ae14be --- /dev/null +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/add-styles-input.tsx @@ -0,0 +1,254 @@ +import { lexer } from "css-tree"; +import { forwardRef, useRef, useState, type KeyboardEvent } from "react"; +import { matchSorter } from "match-sorter"; +import { + Box, + ComboboxAnchor, + ComboboxContent, + ComboboxItemDescription, + ComboboxListbox, + ComboboxListboxItem, + ComboboxRoot, + ComboboxScrollArea, + InputField, + NestedInputButton, + Text, + theme, + useCombobox, +} from "@webstudio-is/design-system"; +import { + properties as propertiesData, + keywordValues, + propertyDescriptions, + parseCssValue, +} from "@webstudio-is/css-data"; +import { + cssWideKeywords, + hyphenateProperty, + type StyleProperty, +} from "@webstudio-is/css-engine"; +import { deleteProperty, setProperty } from "../../shared/use-style-data"; +import { composeEventHandlers } from "~/shared/event-utils"; + +type SearchItem = { property: string; label: string; value?: string }; + +const autoCompleteItems: Array = []; + +const getNewPropertyDescription = (item: null | SearchItem) => { + let description: string | undefined = `Create CSS variable.`; + if (item && item.property in propertyDescriptions) { + description = propertyDescriptions[item.property]; + } + return {description}; +}; + +const getAutocompleteItems = () => { + if (autoCompleteItems.length > 0) { + return autoCompleteItems; + } + for (const property in propertiesData) { + autoCompleteItems.push({ + property, + label: hyphenateProperty(property), + }); + } + + const ignoreValues = new Set([...cssWideKeywords, ...keywordValues.color]); + + for (const property in keywordValues) { + const values = keywordValues[property as keyof typeof keywordValues]; + for (const value of values) { + if (ignoreValues.has(value)) { + continue; + } + autoCompleteItems.push({ + property, + value, + label: `${hyphenateProperty(property)}: ${value}`, + }); + } + } + + autoCompleteItems.sort((a, b) => + Intl.Collator().compare(a.property, b.property) + ); + + return autoCompleteItems; +}; + +const matchOrSuggestToCreate = ( + search: string, + items: Array, + itemToString: (item: SearchItem) => string +) => { + const matched = matchSorter(items, search, { + keys: [itemToString], + }); + + // Limit the array to 100 elements + matched.length = Math.min(matched.length, 100); + + const property = search.trim(); + if ( + property.startsWith("--") && + lexer.match("", property).matched + ) { + matched.unshift({ + property, + label: `Create "${property}"`, + }); + } + // When there is no match we suggest to create a custom property. + if ( + matched.length === 0 && + lexer.match("", `--${property}`).matched + ) { + matched.unshift({ + property: `--${property}`, + label: `--${property}: unset;`, + }); + } + + return matched; +}; + +/** + * + * Advanced search control supports following interactions + * + * find property + * create custom property + * submit css declarations + * paste css declarations + * + */ +export const AddStylesInput = forwardRef< + HTMLInputElement, + { + onClose: () => void; + onSubmit: (css: string) => void; + onFocus: () => void; + onBlur: () => void; + } +>(({ onClose, onSubmit, onFocus, onBlur }, forwardedRef) => { + const [item, setItem] = useState({ + property: "", + label: "", + }); + const highlightedItemRef = useRef(); + + const combobox = useCombobox({ + getItems: getAutocompleteItems, + itemToString: (item) => item?.label ?? "", + value: item, + defaultHighlightedIndex: 0, + getItemProps: () => ({ text: "sentence" }), + match: matchOrSuggestToCreate, + onChange: (value) => setItem({ property: value ?? "", label: value ?? "" }), + onItemSelect: (item) => { + clear(); + onSubmit(`${item.property}: ${item.value ?? "unset"}`); + }, + onItemHighlight: (item) => { + const previousHighlightedItem = highlightedItemRef.current; + if (item?.value === undefined && previousHighlightedItem) { + deleteProperty(previousHighlightedItem.property as StyleProperty, { + isEphemeral: true, + }); + highlightedItemRef.current = undefined; + return; + } + + if (item?.value) { + const value = parseCssValue(item.property as StyleProperty, item.value); + setProperty(item.property as StyleProperty)(value, { + isEphemeral: true, + }); + highlightedItemRef.current = item; + } + }, + }); + + const descriptionItem = combobox.items[combobox.highlightedIndex]; + const description = getNewPropertyDescription(descriptionItem); + const descriptions = combobox.items.map(getNewPropertyDescription); + const inputProps = combobox.getInputProps(); + + const clear = () => { + setItem({ property: "", label: "" }); + }; + + const handleKeys = (event: KeyboardEvent) => { + // Dropdown might handle enter or escape. + if (event.defaultPrevented) { + return; + } + if (event.key === "Enter") { + clear(); + onSubmit(item.property); + return; + } + // When user hits backspace and there is nothing in the input - we hide the input + const abortByBackspace = + event.key === "Backspace" && combobox.inputValue === ""; + + if (event.key === "Escape" || abortByBackspace) { + clear(); + onClose(); + event.preventDefault(); + } + }; + + 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 ( + +
+ + { + inputProps.onBlur(event); + onBlur(); + }} + inputRef={forwardedRef} + onKeyDown={handleKeyDown} + placeholder="Add styles" + suffix={} + /> + + + + + {combobox.items.map((item, index) => ( + + + {item.label} + + + ))} + + {description && ( + + {description} + + )} + + +
+
+ ); +}); 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 f02ee698ec84..024328dacd99 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,35 +1,22 @@ import { mergeRefs } from "@react-aria/utils"; -import { lexer } from "css-tree"; import { colord } from "colord"; import { - forwardRef, memo, useEffect, useRef, useState, type ChangeEvent, type ComponentProps, - type KeyboardEvent, type ReactNode, type RefObject, } from "react"; import { useStore } from "@nanostores/react"; -import { computed } from "nanostores"; import { matchSorter } from "match-sorter"; import { PlusIcon } from "@webstudio-is/icons"; import { Box, - ComboboxAnchor, - ComboboxContent, - ComboboxItemDescription, - ComboboxListbox, - ComboboxListboxItem, - ComboboxRoot, - ComboboxScrollArea, Flex, - InputField, Label, - NestedInputButton, SearchField, SectionTitle, SectionTitleButton, @@ -38,17 +25,9 @@ import { Text, theme, Tooltip, - useCombobox, } from "@webstudio-is/design-system"; +import { parseCss, propertyDescriptions } from "@webstudio-is/css-data"; import { - parseCss, - properties as propertiesData, - keywordValues, - propertyDescriptions, - parseCssValue, -} from "@webstudio-is/css-data"; -import { - cssWideKeywords, hyphenateProperty, toValue, type StyleProperty, @@ -66,24 +45,17 @@ import { } from "../../shared/use-style-data"; import { $availableVariables, - $matchingBreakpoints, - getDefinedStyles, useComputedStyleDecl, useComputedStyles, } from "../../shared/model"; import { getDots } from "../../shared/style-section"; import { PropertyInfo } from "../../property-label"; -import { sections } from "../sections"; import { ColorPopover } from "../../shared/color-picker"; -import { - $registeredComponentMetas, - $styles, - $styleSourceSelections, -} from "~/shared/nano-states"; import { useClientSupports } from "~/shared/client-supports"; -import { $selectedInstancePath } from "~/shared/awareness"; +import { CopyPasteMenu, propertyContainerAttribute } from "./copy-paste-menu"; +import { $advancedStyles } from "./stores"; import { $settings } from "~/builder/shared/client-settings"; -import { composeEventHandlers } from "~/shared/event-utils"; +import { AddStylesInput } from "./add-styles-input"; // Only here to keep the same section module interface export const properties = []; @@ -125,88 +97,6 @@ const AdvancedStyleSection = (props: { ); }; -type SearchItem = { property: string; label: string; value?: string }; - -const autoCompleteItems: Array = []; - -const getAutocompleteItems = () => { - if (autoCompleteItems.length > 0) { - return autoCompleteItems; - } - for (const property in propertiesData) { - autoCompleteItems.push({ - property, - label: hyphenateProperty(property), - }); - } - - const ignoreValues = new Set([...cssWideKeywords, ...keywordValues.color]); - - for (const property in keywordValues) { - const values = keywordValues[property as keyof typeof keywordValues]; - for (const value of values) { - if (ignoreValues.has(value)) { - continue; - } - autoCompleteItems.push({ - property, - value, - label: `${hyphenateProperty(property)}: ${value}`, - }); - } - } - - autoCompleteItems.sort((a, b) => - Intl.Collator().compare(a.property, b.property) - ); - - return autoCompleteItems; -}; - -const matchOrSuggestToCreate = ( - search: string, - items: Array, - itemToString: (item: SearchItem) => string -) => { - const matched = matchSorter(items, search, { - keys: [itemToString], - }); - - // Limit the array to 100 elements - matched.length = Math.min(matched.length, 100); - - const property = search.trim(); - if ( - property.startsWith("--") && - lexer.match("", property).matched - ) { - matched.unshift({ - property, - label: `Create "${property}"`, - }); - } - // When there is no match we suggest to create a custom property. - if ( - matched.length === 0 && - lexer.match("", `--${property}`).matched - ) { - matched.unshift({ - property: `--${property}`, - label: `--${property}: unset;`, - }); - } - - return matched; -}; - -const getNewPropertyDescription = (item: null | SearchItem) => { - let description: string | undefined = `Create CSS variable.`; - if (item && item.property in propertyDescriptions) { - description = propertyDescriptions[item.property]; - } - return {description}; -}; - const insertStyles = (text: string) => { let parsedStyles = parseCss(`selector{${text}}`); if (parsedStyles.length === 0) { @@ -225,142 +115,6 @@ const insertStyles = (text: string) => { return parsedStyles; }; -/** - * - * Advanced search control supports following interactions - * - * find property - * create custom property - * submit css declarations - * paste css declarations - * - */ -const AddProperty = forwardRef< - HTMLInputElement, - { - onClose: () => void; - onSubmit: (css: string) => void; - onFocus: () => void; - onBlur: () => void; - } ->(({ onClose, onSubmit, onFocus, onBlur }, forwardedRef) => { - const [item, setItem] = useState({ - property: "", - label: "", - }); - const highlightedItemRef = useRef(); - - const combobox = useCombobox({ - getItems: getAutocompleteItems, - itemToString: (item) => item?.label ?? "", - value: item, - defaultHighlightedIndex: 0, - getItemProps: () => ({ text: "sentence" }), - match: matchOrSuggestToCreate, - onChange: (value) => setItem({ property: value ?? "", label: value ?? "" }), - onItemSelect: (item) => { - clear(); - onSubmit(`${item.property}: ${item.value ?? "unset"}`); - }, - onItemHighlight: (item) => { - const previousHighlightedItem = highlightedItemRef.current; - if (item?.value === undefined && previousHighlightedItem) { - deleteProperty(previousHighlightedItem.property as StyleProperty, { - isEphemeral: true, - }); - highlightedItemRef.current = undefined; - return; - } - - if (item?.value) { - const value = parseCssValue(item.property as StyleProperty, item.value); - setProperty(item.property as StyleProperty)(value, { - isEphemeral: true, - }); - highlightedItemRef.current = item; - } - }, - }); - - const descriptionItem = combobox.items[combobox.highlightedIndex]; - const description = getNewPropertyDescription(descriptionItem); - const descriptions = combobox.items.map(getNewPropertyDescription); - const inputProps = combobox.getInputProps(); - - const clear = () => { - setItem({ property: "", label: "" }); - }; - - const handleKeys = (event: KeyboardEvent) => { - // Dropdown might handle enter or escape. - if (event.defaultPrevented) { - return; - } - if (event.key === "Enter") { - clear(); - onSubmit(item.property); - 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 ( - -
- - { - inputProps.onBlur(event); - onBlur(); - }} - inputRef={forwardedRef} - onKeyDown={handleKeyDown} - placeholder="Add styles" - suffix={} - /> - - - - - {combobox.items.map((item, index) => ( - - - {item.label} - - - ))} - - {description && ( - - {description} - - )} - - -
-
- ); -}); - // Used to indent the values when they are on the next line. This way its easier to see // where the property ends and value begins, especially in case of presets. const indentation = `20px`; @@ -495,71 +249,6 @@ const AdvancedPropertyValue = ({ ); }; -const initialProperties = new Set([ - "cursor", - "mixBlendMode", - "opacity", - "pointerEvents", - "userSelect", -]); - -const $advancedProperties = computed( - [ - // prevent showing properties inherited from root - // to not bloat advanced panel - $selectedInstancePath, - $registeredComponentMetas, - $styleSourceSelections, - $matchingBreakpoints, - $styles, - $settings, - ], - ( - instancePath, - metas, - styleSourceSelections, - matchingBreakpoints, - styles, - settings - ) => { - if (instancePath === undefined) { - return []; - } - const definedStyles = getDefinedStyles({ - instancePath, - metas, - matchingBreakpoints, - styleSourceSelections, - styles, - }); - // All properties used by the panels except the advanced panel - const baseProperties = new Set([]); - for (const { properties } of sections.values()) { - for (const property of properties) { - baseProperties.add(property); - } - } - const advancedProperties = new Set(); - for (const { property, listed } of definedStyles) { - 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. - // @todo we need to find a better place for them in any case - if (settings.stylePanelMode !== "advanced") { - for (const property of initialProperties) { - advancedProperties.add(property); - } - } - return Array.from(advancedProperties); - } -); - /** * The Advanced section in the Style Panel on Global Root has performance issues. * To fix this, we skip rendering properties not visible in the viewport using the contentvisibilityautostatechange event, @@ -631,6 +320,7 @@ const AdvancedProperty = memo( wrap="wrap" align="center" justify="start" + {...{ [propertyContainerAttribute]: property }} > {isVisible && ( <> @@ -662,7 +352,7 @@ const AdvancedProperty = memo( export const Section = () => { const [isAdding, setIsAdding] = useState(false); - const advancedProperties = useStore($advancedProperties); + const advancedStyles = useStore($advancedStyles); const [recentProperties, setRecentProperties] = useState([]); const addPropertyInputRef = useRef(null); const recentValueInputRef = useRef(null); @@ -672,6 +362,10 @@ export const Section = () => { const containerRef = useRef(null); const [minHeight, setMinHeight] = useState(0); + const advancedProperties = Array.from( + advancedStyles.keys() + ) as Array; + const currentProperties = searchProperties ?? advancedProperties; const showRecentProperties = @@ -681,6 +375,14 @@ export const Section = () => { setMinHeight(containerRef.current?.getBoundingClientRect().height ?? 0); }; + const handleInsertStyles = (cssText: string) => { + const styles = insertStyles(cssText); + const insertedProperties = styles.map(({ property }) => property); + setRecentProperties( + Array.from(new Set([...recentProperties, ...insertedProperties])) + ); + }; + const handleShowAddStylesInput = () => { 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. @@ -692,23 +394,26 @@ export const Section = () => { setSearchProperties(undefined); }; - const handleSubmitStyles = (cssText: string) => { - setIsAdding(false); - const styles = insertStyles(cssText); - const insertedProperties = styles.map(({ property }) => property); - setRecentProperties( - Array.from(new Set([...recentProperties, ...insertedProperties])) - ); - }; - const handleSearch = (event: ChangeEvent) => { const search = event.target.value.trim(); if (search === "") { return handleAbortSearch(); } - memorizeMinHeight(); - const matched = matchSorter(advancedProperties, search); - setSearchProperties(matched); + // This is not needed in advanced mode because the input won't jump there as it is on top + if ($settings.get().stylePanelMode !== "advanced") { + memorizeMinHeight(); + } + + const styles = []; + for (const [property, value] of advancedStyles) { + styles.push({ property, value: toValue(value) }); + } + + const matched = matchSorter(styles, search, { + keys: ["property", "value"], + }).map(({ property }) => property); + + setSearchProperties(matched as StyleProperty[]); }; const handleAbortAddStyles = () => { @@ -733,68 +438,80 @@ export const Section = () => { onAbort={handleAbortSearch} /> - - {showRecentProperties && - recentProperties.map((property, index, properties) => { - const isLast = index === properties.length - 1; - return ( - { - if (event.type === "enter") { - handleShowAddStylesInput(); - } - }} - onReset={() => { - setRecentProperties((properties) => { - return properties.filter( - (recentProperty) => recentProperty !== property - ); - }); - }} - /> - ); - })} - {(showRecentProperties || isAdding) && ( + + + + {showRecentProperties && + recentProperties.map((property, index, properties) => { + const isLast = index === properties.length - 1; + return ( + { + if (event.type === "enter") { + handleShowAddStylesInput(); + } + }} + onReset={() => { + setRecentProperties((properties) => { + return properties.filter( + (recentProperty) => recentProperty !== property + ); + }); + }} + /> + ); + })} + {(showRecentProperties || isAdding) && ( + + { + setIsAdding(false); + handleInsertStyles(cssText); + }} + onClose={handleAbortAddStyles} + onFocus={() => { + if (isAdding === false) { + handleShowAddStylesInput(); + } + }} + onBlur={() => { + setIsAdding(false); + }} + ref={addPropertyInputRef} + /> + + )} + + {showRecentProperties && } - { - if (isAdding === false) { - handleShowAddStylesInput(); - } - }} - onBlur={() => { - setIsAdding(false); - }} - ref={addPropertyInputRef} - /> + {currentProperties + .filter( + (property) => recentProperties.includes(property) === false + ) + .map((property) => ( + + ))} - )} - - {showRecentProperties && } - - {currentProperties - .filter((property) => recentProperties.includes(property) === false) - .map((property) => ( - - ))} - + + ); }; diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/copy-paste-menu.tsx b/apps/builder/app/builder/features/style-panel/sections/advanced/copy-paste-menu.tsx new file mode 100644 index 000000000000..99bf799797b6 --- /dev/null +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/copy-paste-menu.tsx @@ -0,0 +1,95 @@ +import { useRef, type ReactNode } from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + theme, +} from "@webstudio-is/design-system"; +import { + generateStyleMap, + hyphenateProperty, + mergeStyles, + toValue, + type StyleMap, +} from "@webstudio-is/css-engine"; +import { useStore } from "@nanostores/react"; +import { $advancedStyles } from "./stores"; + +export const propertyContainerAttribute = "data-property"; + +export const CopyPasteMenu = ({ + children, + properties, + onPaste, +}: { + children: ReactNode; + properties: Array; + onPaste: (cssText: string) => void; +}) => { + const advancedStyles = useStore($advancedStyles); + const lastClickedProperty = useRef(); + + const handlePaste = () => { + navigator.clipboard.readText().then(onPaste); + }; + + const handleCopyAll = () => { + // We want to only copy properties that are currently in front of the user. + // That includes search or any future filters. + const currentStyleMap: StyleMap = new Map(); + for (const [property, value] of advancedStyles) { + const isEmpty = toValue(value) === ""; + if (properties.includes(property) && isEmpty === false) { + currentStyleMap.set(hyphenateProperty(property), value); + } + } + + const css = generateStyleMap({ style: mergeStyles(currentStyleMap) }); + navigator.clipboard.writeText(css); + }; + + const handleCopy = () => { + const property = lastClickedProperty.current; + if (property === undefined) { + return; + } + const value = advancedStyles.get(property); + if (value === undefined) { + return; + } + const style = new Map([[property, value]]); + const css = generateStyleMap({ style }); + navigator.clipboard.writeText(css); + }; + + return ( + + { + if (!(event.target instanceof HTMLElement)) { + return; + } + const property = event.target.closest( + `[${propertyContainerAttribute}]` + )?.dataset.property; + lastClickedProperty.current = property; + }} + > + {children} + + + + Copy declaration + + + Copy all declarations + + + Paste declarations + + + + ); +}; diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/stores.ts b/apps/builder/app/builder/features/style-panel/sections/advanced/stores.ts new file mode 100644 index 000000000000..8481abcf106b --- /dev/null +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/stores.ts @@ -0,0 +1,78 @@ +import { computed } from "nanostores"; +import { type StyleMap, type StyleProperty } from "@webstudio-is/css-engine"; +import { $matchingBreakpoints, getDefinedStyles } from "../../shared/model"; +import { sections } from "../sections"; +import { + $registeredComponentMetas, + $styles, + $styleSourceSelections, +} from "~/shared/nano-states"; +import { $selectedInstancePath } from "~/shared/awareness"; +import { $settings } from "~/builder/shared/client-settings"; + +const initialProperties = new Set([ + "cursor", + "mixBlendMode", + "opacity", + "pointerEvents", + "userSelect", +]); + +export const $advancedStyles = computed( + [ + // prevent showing properties inherited from root + // to not bloat advanced panel + $selectedInstancePath, + $registeredComponentMetas, + $styleSourceSelections, + $matchingBreakpoints, + $styles, + $settings, + ], + ( + instancePath, + metas, + styleSourceSelections, + matchingBreakpoints, + styles, + settings + ) => { + const advancedStyles: StyleMap = new Map(); + + if (instancePath === undefined) { + return advancedStyles; + } + + const definedStyles = getDefinedStyles({ + instancePath, + metas, + matchingBreakpoints, + styleSourceSelections, + styles, + }); + + // All properties used by the panels except the advanced panel + const visualProperties = new Set([]); + for (const { properties } of sections.values()) { + for (const property of properties) { + visualProperties.add(property); + } + } + for (const style of definedStyles) { + const { property, value, listed } = style; + // 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") { + advancedStyles.set(property, value); + } + } + // In advanced mode we assume user knows the properties they need, so we don't need to show these. + // @todo we need to find a better place for them in any case + if (settings.stylePanelMode !== "advanced") { + for (const initialProperty of initialProperties) { + advancedStyles.set(initialProperty, { type: "unset", value: "" }); + } + } + return advancedStyles; + } +); diff --git a/apps/builder/app/builder/features/style-panel/shared/model.tsx b/apps/builder/app/builder/features/style-panel/shared/model.tsx index 834f6634aef1..5492a915086e 100644 --- a/apps/builder/app/builder/features/style-panel/shared/model.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/model.tsx @@ -144,7 +144,7 @@ export const getDefinedStyles = ({ const matchingBreakpoints = new Set(matchingBreakpointsArray); const startingInstanceSelector = instancePath[0].instanceSelector; - type StyleDeclSubset = Pick; + type StyleDeclSubset = Pick; const instanceStyles = new Set(); const inheritedStyles = new Set(); diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 4fcf36ce19a5..0c02aa19da1d 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", diff --git a/packages/design-system/src/components/context-menu.tsx b/packages/design-system/src/components/context-menu.tsx new file mode 100644 index 000000000000..fb5379ba1b7c --- /dev/null +++ b/packages/design-system/src/components/context-menu.tsx @@ -0,0 +1,149 @@ +import { + forwardRef, + type ComponentProps, + type ElementRef, + type ReactElement, + type ReactNode, +} from "react"; +import { ChevronRightIcon } from "@webstudio-is/icons"; +import { styled } from "../stitches.config"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { + menuCss, + subMenuCss, + separatorCss, + menuItemCss, + labelCss, + menuItemIndicatorCss, + subContentProps, + MenuCheckedIcon, +} from "./menu"; +export { DropdownMenuArrow } from "./menu"; + +export const ContextMenu = ContextMenuPrimitive.Root; + +export const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +export const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuContentStyled = styled(ContextMenuPrimitive.Content, menuCss); +export const ContextMenuContent = forwardRef< + ElementRef, + ComponentProps +>((props, ref) => { + return ( + + + + ); +}); + +const SubContentStyled = styled(ContextMenuPrimitive.SubContent, subMenuCss); +export const ContextMenuSubContent = forwardRef< + ElementRef, + ComponentProps +>((props, forwardedRef) => ( + +)); +ContextMenuSubContent.displayName = "ContextMenuSubContent"; + +export const ContextMenuSeparator = styled( + ContextMenuPrimitive.Separator, + separatorCss +); + +export const ContextMenuLabel = styled(ContextMenuPrimitive.Label, labelCss); + +const StyledMenuItem = styled(ContextMenuPrimitive.Item, menuItemCss, { + defaultVariants: { withIndicator: true }, +}); +export const ContextMenuItem = forwardRef< + ElementRef, + ComponentProps & { icon?: ReactNode } +>(({ icon, children, withIndicator, ...props }, forwardedRef) => + icon ? ( + +
{icon}
+ {children} +
+ ) : ( + + {children} + + ) +); +ContextMenuItem.displayName = "ContextMenuItem"; + +export const ContextMenuItemRightSlot = styled("span", { + marginLeft: "auto", + display: "flex", +}); + +const SubTriggerStyled = styled(ContextMenuPrimitive.SubTrigger, menuItemCss, { + defaultVariants: { withIndicator: true }, +}); +export const ContextMenuSubTrigger = forwardRef< + ElementRef, + ComponentProps & { icon?: ReactNode } +>(({ children, withIndicator, icon, ...props }, forwardedRef) => ( + + {icon &&
{icon}
} + {children} + + + +
+)); +ContextMenuSubTrigger.displayName = "ContextMenuSubTrigger"; + +const Indicator = styled( + ContextMenuPrimitive.ItemIndicator, + menuItemIndicatorCss +); + +const StyledRadioItem = styled(ContextMenuPrimitive.RadioItem, menuItemCss); +export const ContextMenuRadioItem = forwardRef< + ElementRef, + ComponentProps & { icon?: ReactElement } +>(({ children, icon, ...props }, forwardedRef) => ( + + {icon !== undefined && {icon}} + {children} + +)); +ContextMenuRadioItem.displayName = "ContextMenuRadioItem"; + +const StyledCheckboxItem = styled( + ContextMenuPrimitive.CheckboxItem, + menuItemCss +); +export const ContextMenuCheckboxItem = forwardRef< + ElementRef, + ComponentProps & { icon?: ReactNode } +>(({ children, icon = , ...props }, forwardedRef) => ( + + {icon} + {children} + +)); +ContextMenuCheckboxItem.displayName = "ContextMenuCheckboxItem"; + +export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +export const ContextMenuGroup = ContextMenuPrimitive.Group; diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index e93af0c5c17c..45056946c7f9 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -13,6 +13,7 @@ export * from "./components/label"; export * from "./components/select"; export * from "./components/combobox"; export * from "./components/dropdown-menu"; +export * from "./components/context-menu"; export * from "./components/icon-button"; // mostly aligned, but needs a demo and to use tokens export * from "./components/toggle-button"; export * from "./components/dialog"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 546ef4f7a386..e590cd5f768c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1321,6 +1321,9 @@ importers: '@radix-ui/react-collapsible': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-context-menu': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) '@radix-ui/react-dialog': specifier: ^1.1.6 version: 1.1.6(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) @@ -4054,6 +4057,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.6': + resolution: {integrity: sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==} + peerDependencies: + '@types/react': ^18.2.70 + '@types/react-dom': ^18.2.25 + react: 18.3.0-canary-14898b6a9-20240318 + react-dom: 18.3.0-canary-14898b6a9-20240318 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.0.1': resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} peerDependencies: @@ -11178,6 +11194,20 @@ snapshots: optionalDependencies: '@types/react': 18.2.79 + '@radix-ui/react-context-menu@2.2.6(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-context': 1.1.1(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-menu': 2.1.6(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318) + react: 18.3.0-canary-14898b6a9-20240318 + react-dom: 18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.2.25 + '@radix-ui/react-context@1.0.1(@types/react@18.2.79)(react@18.3.0-canary-14898b6a9-20240318)': dependencies: '@babel/runtime': 7.22.3