From a8bfa4d37a2aeeaa2c4491d47769cafc471b9f8d Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Wed, 5 Feb 2025 18:54:20 +0000 Subject: [PATCH] experimental: Style panel focus mode (#4835) https://github.com/webstudio-is/webstudio/issues/4816 ## Description - implements accordion pattern - adds a dropdown switch ## Steps for reproduction 1. click button 2. 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 --- .../builder/features/inspector/inspector.tsx | 39 ++-- .../settings-panel/variables-section.tsx | 6 +- .../features/style-panel/property-label.tsx | 2 +- .../sections/advanced/advanced.tsx | 2 +- .../sections/transforms/transforms.tsx | 14 +- .../sections/transitions/transitions.tsx | 8 +- .../style-panel/shared/style-section.tsx | 6 +- .../features/style-panel/style-panel.tsx | 93 ++++++++- .../app/builder/features/topbar/topbar.tsx | 2 +- .../outline/block-instance-outline.tsx | 4 +- .../shared/client-settings/settings.ts | 1 + .../builder/shared/collapsible-section.tsx | 184 +++++++++++------- apps/builder/app/builder/shared/commands.ts | 12 ++ packages/design-system/src/components/kbd.tsx | 2 +- .../design-system/src/components/menu.tsx | 2 +- 15 files changed, 267 insertions(+), 110 deletions(-) diff --git a/apps/builder/app/builder/features/inspector/inspector.tsx b/apps/builder/app/builder/features/inspector/inspector.tsx index 45e8dc8c59d9..e823a04456e8 100644 --- a/apps/builder/app/builder/features/inspector/inspector.tsx +++ b/apps/builder/app/builder/features/inspector/inspector.tsx @@ -19,7 +19,7 @@ import { Kbd, FloatingPanelProvider, } from "@webstudio-is/design-system"; -import { StylePanel } from "~/builder/features/style-panel"; +import { ModeMenu, StylePanel } from "~/builder/features/style-panel"; import { SettingsPanelContainer } from "~/builder/features/settings-panel"; import { $registeredComponentMetas, @@ -33,6 +33,7 @@ import { getInstanceLabel } from "~/shared/instance-utils"; import { BindingPopoverProvider } from "~/builder/shared/binding-popover"; import { $activeInspectorPanel } from "~/builder/shared/nano-states"; import { $selectedInstance, $selectedPage } from "~/shared/awareness"; +import { isFeatureEnabled } from "@webstudio-is/feature-flags"; const InstanceInfo = ({ instance }: { instance: Instance }) => { const metas = useStore($registeredComponentMetas); @@ -42,16 +43,7 @@ const InstanceInfo = ({ instance }: { instance: Instance }) => { } const label = getInstanceLabel(instance, componentMeta); return ( - + {label} @@ -175,7 +167,18 @@ export const Inspector = ({ navigatorLayout }: InspectorProps) => { - + + + {isFeatureEnabled("stylePanelModes") && } + { tabIndex={-1} > - + + + { ); }; +const label = "Data Variables"; + export const VariablesSection = () => { const containerRef = useRef(null); - const [isOpen, setIsOpen] = useOpenState({ label: "variables" }); + const [isOpen, setIsOpen] = useOpenState(label); return ( } - suffix={} + suffix={} css={{ gridTemplateColumns: "1fr max-content 1fr" }} onClick={onReset} > 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 fe38c2277cd0..3eb13d1b0795 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 @@ -83,7 +83,7 @@ const AdvancedStyleSection = (props: { children: ReactNode; }) => { const { label, children, properties, onAdd } = props; - const [isOpen, setIsOpen] = useOpenState(props); + const [isOpen, setIsOpen] = useOpenState(label); const styles = useComputedStyles(properties); return ( { }; export const Section = () => { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useOpenState(label); const styles = useComputedStyles(properties); const isAnyTransformPropertyAdded = transformPanels.some((panel) => diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx b/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx index 216cd9cf6344..45516aacf657 100644 --- a/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { useStore } from "@nanostores/react"; import { PlusIcon } from "@webstudio-is/icons"; import { @@ -12,7 +11,10 @@ import { type LayerValueItem, type StyleProperty, } from "@webstudio-is/css-engine"; -import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section"; +import { + CollapsibleSectionRoot, + useOpenState, +} from "~/builder/shared/collapsible-section"; import { $selectedOrLastStyleSourceSelector } from "~/shared/nano-states"; import { humanizeString } from "~/shared/string-utils"; import { repeatUntil } from "~/shared/array-utils"; @@ -83,7 +85,7 @@ const getLayerLabel = ({ }; export const Section = () => { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useOpenState(label); const selectedOrLastStyleSourceSelector = useStore( $selectedOrLastStyleSourceSelector diff --git a/apps/builder/app/builder/features/style-panel/shared/style-section.tsx b/apps/builder/app/builder/features/style-panel/shared/style-section.tsx index 314d759aaa05..7b161661fb0c 100644 --- a/apps/builder/app/builder/features/style-panel/shared/style-section.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/style-section.tsx @@ -41,11 +41,9 @@ export const StyleSection = (props: { // @todo remove to keep sections consistent fullWidth?: boolean; children: ReactNode; - accordion?: string; - initialOpen?: string; }) => { const { label, children, properties, fullWidth } = props; - const [isOpen, setIsOpen] = useOpenState(props); + const [isOpen, setIsOpen] = useOpenState(label); const styles = useComputedStyles(properties); return ( { const { label, description, children, properties, onAdd, collapsible } = props; - const [isOpen, setIsOpen] = useOpenState(props); + const [isOpen, setIsOpen] = useOpenState(label); const styles = useComputedStyles(properties); const dots = getDots(styles); 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 be36c6d7539a..f4fd0aeae3a5 100644 --- a/apps/builder/app/builder/features/style-panel/style-panel.tsx +++ b/apps/builder/app/builder/features/style-panel/style-panel.tsx @@ -5,6 +5,18 @@ import { Text, Separator, ScrollArea, + DropdownMenu, + DropdownMenuTrigger, + IconButton, + DropdownMenuContent, + DropdownMenuRadioItem, + MenuCheckedIcon, + DropdownMenuRadioGroup, + rawTheme, + Kbd, + Flex, + DropdownMenuSeparator, + DropdownMenuItem, } from "@webstudio-is/design-system"; import { useStore } from "@nanostores/react"; import { computed } from "nanostores"; @@ -14,8 +26,15 @@ import { sections } from "./sections"; import { toValue } from "@webstudio-is/css-engine"; import { $instanceTags, useParentComputedStyleDecl } from "./shared/model"; import { $selectedInstance } from "~/shared/awareness"; -import { CollapsibleSectionContext } from "~/builder/shared/collapsible-section"; -import { isFeatureEnabled } from "@webstudio-is/feature-flags"; +import { CollapsibleProvider } from "~/builder/shared/collapsible-section"; +import { EllipsesIcon } from "@webstudio-is/icons"; +import { + $settings, + getSetting, + setSetting, + type Settings, +} from "~/builder/shared/client-settings"; +import { useState } from "react"; const $selectedInstanceTag = computed( [$selectedInstance, $instanceTags], @@ -27,7 +46,67 @@ const $selectedInstanceTag = computed( } ); +export const ModeMenu = () => { + const value = getSetting("stylePanelMode"); + const [focusedValue, setFocusedValue] = useState(value); + + return ( + + + + + + + + { + setSetting("stylePanelMode", value as Settings["stylePanelMode"]); + }} + > + } + onFocus={() => setFocusedValue("default")} + > + Default + + } + onFocus={() => setFocusedValue("focus")} + > + + Focus mode + + + {/* + }> + Advanced mode + */} + + + + {focusedValue === "default" && ( + + All sections are open by default. + + )} + {focusedValue === "focus" && ( + + Only one section is open at a time. + + )} + + + ); +}; + export const StylePanel = () => { + const { stylePanelMode } = useStore($settings); const selectedInstanceRenderState = useStore($selectedInstanceRenderState); const tag = useStore($selectedInstanceTag); const display = useParentComputedStyleDecl("display"); @@ -75,14 +154,12 @@ export const StylePanel = () => { - {all} - + ); diff --git a/apps/builder/app/builder/features/topbar/topbar.tsx b/apps/builder/app/builder/features/topbar/topbar.tsx index 2015df68f452..b19fbaf03398 100644 --- a/apps/builder/app/builder/features/topbar/topbar.tsx +++ b/apps/builder/app/builder/features/topbar/topbar.tsx @@ -41,7 +41,7 @@ const PagesButton = () => { content={ {"Pages or page settings "} - + } > diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx index 241c96b400f3..66f797bf786e 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx @@ -203,7 +203,7 @@ export const TemplatesMenu = ({ display: hasChildren ? undefined : "none", }} > - to add before + to add before @@ -303,7 +303,7 @@ export const BlockChildHoveredInstanceOutline = () => { gap={1} css={{ order: 1, display: !hasChildren ? "none" : undefined }} > - {" "} + {" "} to delete diff --git a/apps/builder/app/builder/shared/client-settings/settings.ts b/apps/builder/app/builder/shared/client-settings/settings.ts index 73938528e6a6..54c8befc1fc9 100644 --- a/apps/builder/app/builder/shared/client-settings/settings.ts +++ b/apps/builder/app/builder/shared/client-settings/settings.ts @@ -5,6 +5,7 @@ const Settings = z.object({ navigatorLayout: z.enum(["docked", "undocked"]).default("undocked"), isAiMenuOpen: z.boolean().default(true), isAiCommandBarVisible: z.boolean().default(false), + stylePanelMode: z.enum(["default", "focus", "advanced"]).default("default"), }); export type Settings = z.infer; diff --git a/apps/builder/app/builder/shared/collapsible-section.tsx b/apps/builder/app/builder/shared/collapsible-section.tsx index 5cff9df84472..7cbd4c263dcf 100644 --- a/apps/builder/app/builder/shared/collapsible-section.tsx +++ b/apps/builder/app/builder/shared/collapsible-section.tsx @@ -11,7 +11,10 @@ import { } from "@webstudio-is/design-system"; import { createContext, + useCallback, useContext, + useEffect, + useState, type ComponentProps, type ReactNode, } from "react"; @@ -20,46 +23,94 @@ import type { Simplify } from "type-fest"; type Label = string; -type UseOpenStateProps = { - label: Label; - isOpen?: boolean; +type State = { + [label: string]: boolean; }; -export const CollapsibleSectionContext = createContext<{ +// Preserves the open/close state even when component gets unmounted +const $state = atom({}); + +type HandleOpenState = ( + label: Label, + isOpenForced?: boolean +) => [boolean, (value: boolean) => void]; + +const CollapsibleSectionContext = createContext< + | undefined + | { + handleOpenState: HandleOpenState; + } +>(undefined); + +export const CollapsibleProvider = ({ + children, + accordion, + initialOpen, +}: { + children: ReactNode; accordion?: boolean; initialOpen?: Label; -}>({}); +}) => { + const state = useStore($state); + useEffect(() => { + const nextState = { ...$state.get() }; -const $stateContainer = atom<{ - [label: string]: boolean; -}>({}); + if (initialOpen === "*") { + for (const key in nextState) { + nextState[key] = true; + } + $state.set(nextState); + return; + } -// Preserves the open/close state even when component gets unmounted -export const useOpenState = ({ - label, - isOpen: isOpenForced, -}: UseOpenStateProps): [boolean, (value: boolean) => void] => { - const state = useStore($stateContainer); - const { accordion, initialOpen } = useContext(CollapsibleSectionContext); - const setIsOpen = (isOpen: boolean) => { - const update = { ...state }; - if (isOpen && accordion) { - // In accordion mode we close everything else within that accordion. - for (const key in update) { - update[key] = false; + if (accordion) { + for (const key in nextState) { + nextState[key] = false; } } - update[label] = isOpen; - $stateContainer.set(update); - }; - // Set initial value for accordion mode. - if (accordion && state[label] === undefined) { - state[label] = initialOpen === label; - } + if (initialOpen) { + nextState[initialOpen] = true; + } + $state.set(nextState); + }, [accordion, initialOpen]); + + const handleOpenState: HandleOpenState = useCallback( + (label, isOpenForced) => { + const nextState = { ...state }; + if (nextState[label] === undefined) { + nextState[label] = accordion ? false : true; + } - const isOpenCurrent = state[label]; - return [isOpenForced ?? isOpenCurrent ?? true, setIsOpen]; + const setIsOpen = (isOpen: boolean) => { + if (accordion) { + for (const key in nextState) { + nextState[key] = false; + } + } + nextState[label] = isOpen; + $state.set(nextState); + }; + + return [isOpenForced ?? nextState[label], setIsOpen]; + }, + [state, accordion] + ); + + return ( + + {children} + + ); +}; + +export const useOpenState: HandleOpenState = (label, isOpen) => { + const context = useContext(CollapsibleSectionContext); + const localState = useState(isOpen ?? true); + if (context === undefined) { + return localState; + } + return context.handleOpenState(label, isOpen); }; type CollapsibleSectionBaseProps = { @@ -67,8 +118,8 @@ type CollapsibleSectionBaseProps = { children: ReactNode; fullWidth?: boolean; label: string; - isOpen: boolean; - onOpenChange: (value: boolean) => void; + isOpen?: boolean; + onOpenChange?: (value: boolean) => void; }; export const CollapsibleSectionRoot = ({ @@ -78,44 +129,47 @@ export const CollapsibleSectionRoot = ({ fullWidth = false, isOpen, onOpenChange, -}: CollapsibleSectionBaseProps) => ( - - <> - - {trigger ?? ( - - {label} - - )} - - - - - {children} - - - - - -); +}: CollapsibleSectionBaseProps) => { + return ( + + <> + + {trigger ?? ( + + {label} + + )} + + + + + {children} + + + + + + ); +}; type CollapsibleSectionProps = Simplify< - Omit & - UseOpenStateProps + Omit & { + label: Label; + } >; export const CollapsibleSection = (props: CollapsibleSectionProps) => { const { label, trigger, children, fullWidth } = props; - const [isOpen, setIsOpen] = useOpenState(props); + const [isOpen, setIsOpen] = useOpenState(label, props.isOpen); return ( ["dots"]; }) => { const { label, children } = props; - const [isOpen, setIsOpen] = useOpenState(props); + const [isOpen, setIsOpen] = useOpenState(label, props.isOpen); const isEmpty = hasItems === false || (Array.isArray(hasItems) && hasItems.length === 0); diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 185d170d54c1..829489ebfac2 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -43,6 +43,7 @@ import { isInstanceDetachable, isTreeMatching, } from "~/shared/matcher"; +import { getSetting, setSetting } from "./client-settings"; const makeBreakpointCommand = ( name: CommandName, @@ -350,6 +351,17 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ }, disableOnInputLikeControls: true, }, + { + name: "toggleStylePanelFocusMode", + defaultHotkeys: ["alt+shift+s"], + handler: () => { + setSetting( + "stylePanelMode", + getSetting("stylePanelMode") === "focus" ? "default" : "focus" + ); + }, + disableOnInputLikeControls: true, + }, { name: "openSettingsPanel", defaultHotkeys: ["d"], diff --git a/packages/design-system/src/components/kbd.tsx b/packages/design-system/src/components/kbd.tsx index 5c9abe76c243..5715d887b716 100644 --- a/packages/design-system/src/components/kbd.tsx +++ b/packages/design-system/src/components/kbd.tsx @@ -6,7 +6,7 @@ const isMac = const shortcutSymbolMap: Record = { cmd: isMac ? "⌘" : "Ctrl", shift: "⇧", - option: isMac ? "⌥" : "Alt", + alt: isMac ? "⌥" : "Alt", backspace: "⌫", enter: "↵", tab: isMac ? "tab" : "Tab", diff --git a/packages/design-system/src/components/menu.tsx b/packages/design-system/src/components/menu.tsx index e27046596cf9..496b8aec42b3 100644 --- a/packages/design-system/src/components/menu.tsx +++ b/packages/design-system/src/components/menu.tsx @@ -61,7 +61,7 @@ export const menuItemCss = css({ borderRadius: theme.borderRadius[3], // override button default styles backgroundColor: "transparent", - "&:focus, &[data-found], &[aria-selected=true], &[data-state=open], &[data-state=checked]": + "&:focus, &[data-found], &[aria-selected=true], &[data-state=open], &[data-state=checked]:is(:hover,:focus)": { backgroundColor: theme.colors.backgroundItemMenuItemHover, },