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
This commit is contained in:
Oleg Isonen authored and GitHub committed 2025-02-05 19:54:20 +01:00
1 parent 609fdb47da
commit a8bfa4d37a
15 files changed
+266 -109

No files matched your search

@@ -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 (
<Flex
shrink="false"
gap="1"
align="center"
css={{
p: theme.panel.padding,
pb: 0,
color: theme.colors.foregroundSubtle,
}}
>
<Flex shrink={false} gap="1" align="center">
<MetaIcon icon={componentMeta.icon} />
<Text truncate variant="labelsSentenceCase">
{label}
@@ -175,7 +167,18 @@ export const Inspector = ({ navigatorLayout }: InspectorProps) => {
</PanelTabsList>
<Separator />
<PanelTabsContent value="style" css={contentStyle} tabIndex={-1}>
<InstanceInfo instance={selectedInstance} />
<Flex
justify="between"
align="center"
shrink={false}
css={{
paddingInline: theme.panel.paddingInline,
height: theme.spacing[13],
}}
>
<InstanceInfo instance={selectedInstance} />
{isFeatureEnabled("stylePanelModes") && <ModeMenu />}
</Flex>
<StylePanel />
</PanelTabsContent>
<PanelTabsContent
@@ -184,7 +187,17 @@ export const Inspector = ({ navigatorLayout }: InspectorProps) => {
tabIndex={-1}
>
<ScrollArea>
<InstanceInfo instance={selectedInstance} />
<Flex
justify="between"
align="center"
shrink={false}
css={{
paddingInline: theme.panel.paddingInline,
height: theme.spacing[13],
}}
>
<InstanceInfo instance={selectedInstance} />
</Flex>
<SettingsPanelContainer
// Re-render when instance changes
key={selectedInstance.id}
@@ -310,13 +310,15 @@ const VariablesList = () => {
);
};
const label = "Data Variables";
export const VariablesSection = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useOpenState({ label: "variables" });
const [isOpen, setIsOpen] = useOpenState(label);
return (
<VariablePopoverProvider value={{ containerRef }}>
<CollapsibleSectionRoot
label="Data Variables"
label={label}
fullWidth={true}
isOpen={isOpen}
onOpenChange={setIsOpen}
@@ -199,7 +199,7 @@ export const PropertyInfo = ({
<ResetIcon />
</Flex>
}
suffix={<Kbd value={["option", "click"]} color="moreSubtle" />}
suffix={<Kbd value={["alt", "click"]} color="moreSubtle" />}
css={{ gridTemplateColumns: "1fr max-content 1fr" }}
onClick={onReset}
>
@@ -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 (
<CollapsibleSectionRoot
@@ -1,9 +1,4 @@
import {
forwardRef,
useState,
type ElementRef,
type ComponentProps,
} from "react";
import { forwardRef, type ElementRef, type ComponentProps } from "react";
import type { StyleProperty } from "@webstudio-is/css-engine";
import { propertyDescriptions } from "@webstudio-is/css-data";
import {
@@ -32,7 +27,10 @@ import {
EyeOpenIcon,
EllipsesIcon,
} from "@webstudio-is/icons";
import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section";
import {
CollapsibleSectionRoot,
useOpenState,
} from "~/builder/shared/collapsible-section";
import {
addDefaultsForTransormSection,
isTransformPanelPropertyUsed,
@@ -138,7 +136,7 @@ const TransformAdvancedPopover = () => {
};
export const Section = () => {
const [isOpen, setIsOpen] = useState(true);
const [isOpen, setIsOpen] = useOpenState(label);
const styles = useComputedStyles(properties);
const isAnyTransformPropertyAdded = transformPanels.some((panel) =>
@@ -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
@@ -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 (
<CollapsibleSectionRoot
@@ -74,7 +72,7 @@ export const RepeatedStyleSection = (props: {
}) => {
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);
@@ -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<string>(value);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton>
<EllipsesIcon />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={Number.parseFloat(rawTheme.spacing[5])}
css={{ width: theme.spacing[25] }}
>
<DropdownMenuRadioGroup
value={value}
onValueChange={(value) => {
setSetting("stylePanelMode", value as Settings["stylePanelMode"]);
}}
>
<DropdownMenuRadioItem
value="default"
icon={<MenuCheckedIcon />}
onFocus={() => setFocusedValue("default")}
>
Default
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="focus"
icon={<MenuCheckedIcon />}
onFocus={() => setFocusedValue("focus")}
>
<Flex justify="between" grow>
<Text>Focus mode</Text> <Kbd value={["alt", "shift", "s"]} />
</Flex>
</DropdownMenuRadioItem>
{/*
<DropdownMenuRadioItem value="advanced" icon={<MenuCheckedIcon />}>
Advanced mode
</DropdownMenuRadioItem> */}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{focusedValue === "default" && (
<DropdownMenuItem hint>
All sections are open by default.
</DropdownMenuItem>
)}
{focusedValue === "focus" && (
<DropdownMenuItem hint>
Only one section is open at a time.
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
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 = () => {
</Box>
<Separator />
<ScrollArea>
<CollapsibleSectionContext.Provider
value={{
accordion: isFeatureEnabled("stylePanelModes"),
initialOpen: "Layout",
}}
<CollapsibleProvider
accordion={stylePanelMode === "focus"}
initialOpen={stylePanelMode === "focus" ? "Layout" : "*"}
>
{all}
</CollapsibleSectionContext.Provider>
</CollapsibleProvider>
</ScrollArea>
</>
);
@@ -41,7 +41,7 @@ const PagesButton = () => {
content={
<Text>
{"Pages or page settings "}
<Kbd value={["option", "click"]} color="moreSubtle" />
<Kbd value={["alt", "click"]} color="moreSubtle" />
</Text>
}
>
@@ -203,7 +203,7 @@ export const TemplatesMenu = ({
display: hasChildren ? undefined : "none",
}}
>
<Kbd value={["option", "click"]} /> <Text>to add before</Text>
<Kbd value={["alt", "click"]} /> <Text>to add before</Text>
</Flex>
</Grid>
</div>
@@ -303,7 +303,7 @@ export const BlockChildHoveredInstanceOutline = () => {
gap={1}
css={{ order: 1, display: !hasChildren ? "none" : undefined }}
>
<Kbd value={["option", "click"]} color="contrast" />{" "}
<Kbd value={["alt", "click"]} color="contrast" />{" "}
<Text color="subtle">to delete</Text>
</Flex>
</Grid>
@@ -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<typeof Settings>;
@@ -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<State>({});
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 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 (
<CollapsibleSectionContext.Provider value={{ handleOpenState }}>
{children}
</CollapsibleSectionContext.Provider>
);
};
export const useOpenState: HandleOpenState = (label, isOpen) => {
const context = useContext(CollapsibleSectionContext);
const localState = useState(isOpen ?? true);
if (context === undefined) {
return localState;
}
const isOpenCurrent = state[label];
return [isOpenForced ?? isOpenCurrent ?? true, setIsOpen];
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) => (
<Collapsible.Root open={isOpen} onOpenChange={onOpenChange}>
<>
<Collapsible.Trigger asChild>
{trigger ?? (
<SectionTitle>
<SectionTitleLabel>{label}</SectionTitleLabel>
</SectionTitle>
)}
</Collapsible.Trigger>
}: CollapsibleSectionBaseProps) => {
return (
<Collapsible.Root open={isOpen} onOpenChange={onOpenChange}>
<>
<Collapsible.Trigger asChild>
{trigger ?? (
<SectionTitle>
<SectionTitleLabel>{label}</SectionTitleLabel>
</SectionTitle>
)}
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Flex
gap="2"
direction="column"
css={{
pb: theme.panel.paddingBlock,
px: fullWidth ? 0 : theme.panel.paddingInline,
paddingTop: 0,
"&:empty": { display: "none" },
}}
>
{children}
</Flex>
</Collapsible.Content>
<Separator />
</>
</Collapsible.Root>
);
<Collapsible.Content asChild>
<Flex
gap="2"
direction="column"
css={{
pb: theme.panel.paddingBlock,
px: fullWidth ? 0 : theme.panel.paddingInline,
paddingTop: 0,
"&:empty": { display: "none" },
}}
>
{children}
</Flex>
</Collapsible.Content>
<Separator />
</>
</Collapsible.Root>
);
};
type CollapsibleSectionProps = Simplify<
Omit<CollapsibleSectionBaseProps, "isOpen" | "onOpenChange"> &
UseOpenStateProps
Omit<CollapsibleSectionBaseProps, "onOpenChange"> & {
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 (
<CollapsibleSectionRoot
label={label}
@@ -143,7 +197,7 @@ export const CollapsibleSectionWithAddButton = ({
hasItems?: boolean | ComponentProps<typeof SectionTitle>["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);
@@ -43,6 +43,7 @@ import {
isInstanceDetachable,
isTreeMatching,
} from "~/shared/matcher";
import { getSetting, setSetting } from "./client-settings";
const makeBreakpointCommand = <CommandName extends string>(
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"],
@@ -6,7 +6,7 @@ const isMac =
const shortcutSymbolMap: Record<string, string> = {
cmd: isMac ? "⌘" : "Ctrl",
shift: "⇧",
option: isMac ? "⌥" : "Alt",
alt: isMac ? "⌥" : "Alt",
backspace: "⌫",
enter: "↵",
tab: isMac ? "tab" : "Tab",
@@ -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,
},