Skip to content

Commit b8a341c

Browse files
authored
experimental: add tabbable command actions (#4407)
Ref #1696 Here adding new idea into command search. User searches for an object and can navigate actions over this object. In case of pages user can open page or open pangel settings panel. https://github.com/user-attachments/assets/a5d6c039-ba6b-4dbb-b0b4-bb9f1782dd20
1 parent c843747 commit b8a341c

File tree

8 files changed

+328
-77
lines changed

8 files changed

+328
-77
lines changed

apps/builder/app/builder/features/command-panel/command-panel.tsx

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { atom, computed } from "nanostores";
22
import { useStore } from "@nanostores/react";
3+
import { useState } from "react";
4+
import { matchSorter } from "match-sorter";
35
import {
46
collectionComponent,
57
componentCategories,
@@ -15,15 +17,19 @@ import {
1517
CommandGroupHeading,
1618
CommandItem,
1719
CommandIcon,
20+
useSelectedAction,
1821
ScrollArea,
1922
Flex,
2023
Kbd,
2124
Text,
25+
CommandFooter,
26+
Separator,
2227
} from "@webstudio-is/design-system";
2328
import { compareMedia } from "@webstudio-is/css-engine";
2429
import type { Breakpoint, Page } from "@webstudio-is/sdk";
2530
import {
2631
$breakpoints,
32+
$editingPageId,
2733
$pages,
2834
$registeredComponentMetas,
2935
$selectedBreakpoint,
@@ -34,9 +40,8 @@ import { humanizeString } from "~/shared/string-utils";
3440
import { setCanvasWidth } from "~/builder/features/breakpoints";
3541
import { insert as insertComponent } from "~/builder/features/components/insert";
3642
import { $selectedPage, selectPage } from "~/shared/awareness";
37-
import { useState } from "react";
38-
import { matchSorter } from "match-sorter";
3943
import { mapGroupBy } from "~/shared/shim";
44+
import { setActiveSidebarPanel } from "~/builder/shared/nano-states";
4045

4146
const $commandPanel = atom<
4247
| undefined
@@ -124,30 +129,35 @@ const $componentOptions = computed(
124129
}
125130
);
126131

127-
const ComponentsGroup = ({ options }: { options: ComponentOption[] }) => {
132+
const ComponentGroup = ({ options }: { options: ComponentOption[] }) => {
128133
return (
129134
<CommandGroup
135+
name="component"
130136
heading={<CommandGroupHeading>Components</CommandGroupHeading>}
137+
actions={["add"]}
131138
>
132139
{options.map(({ component, label, meta }) => {
133140
return (
134141
<CommandItem
135142
key={component}
136-
keywords={["Components"]}
143+
// preserve selected state when rerender
144+
value={component}
137145
onSelect={() => {
138146
closeCommandPanel();
139147
insertComponent(component);
140148
}}
141149
>
142-
<CommandIcon
143-
dangerouslySetInnerHTML={{ __html: meta.icon }}
144-
></CommandIcon>
145-
<Text variant="labelsTitleCase">
146-
{label}{" "}
147-
<Text as="span" color="moreSubtle">
148-
{humanizeString(meta.category ?? "")}
150+
<Flex gap={2}>
151+
<CommandIcon
152+
dangerouslySetInnerHTML={{ __html: meta.icon }}
153+
></CommandIcon>
154+
<Text variant="labelsTitleCase">
155+
{label}{" "}
156+
<Text as="span" color="moreSubtle">
157+
{humanizeString(meta.category ?? "")}
158+
</Text>
149159
</Text>
150-
</Text>
160+
</Flex>
151161
</CommandItem>
152162
);
153163
})}
@@ -198,21 +208,24 @@ const getBreakpointLabel = (breakpoint: Breakpoint) => {
198208
return `${breakpoint.label}: ${label}`;
199209
};
200210

201-
const BreakpointsGroup = ({ options }: { options: BreakpointOption[] }) => {
211+
const BreakpointGroup = ({ options }: { options: BreakpointOption[] }) => {
202212
return (
203213
<CommandGroup
214+
name="breakpoint"
204215
heading={<CommandGroupHeading>Breakpoints</CommandGroupHeading>}
216+
actions={["select"]}
205217
>
206218
{options.map(({ breakpoint, shortcut }) => (
207219
<CommandItem
208220
key={breakpoint.id}
221+
// preserve selected state when rerender
222+
value={breakpoint.id}
209223
onSelect={() => {
210224
closeCommandPanel({ restoreFocus: true });
211225
$selectedBreakpointId.set(breakpoint.id);
212226
setCanvasWidth(breakpoint.id);
213227
}}
214228
>
215-
<CommandIcon></CommandIcon>
216229
<Text variant="labelsTitleCase">
217230
{getBreakpointLabel(breakpoint)}
218231
</Text>
@@ -249,18 +262,33 @@ const $pageOptions = computed(
249262
}
250263
);
251264

252-
const PagesGroup = ({ options }: { options: PageOption[] }) => {
265+
const PageGroup = ({ options }: { options: PageOption[] }) => {
266+
const action = useSelectedAction();
253267
return (
254-
<CommandGroup heading={<CommandGroupHeading>Pages</CommandGroupHeading>}>
268+
<CommandGroup
269+
name="page"
270+
heading={<CommandGroupHeading>Pages</CommandGroupHeading>}
271+
actions={["select", "settings"]}
272+
>
255273
{options.map(({ page }) => (
256274
<CommandItem
257275
key={page.id}
276+
// preserve selected state when rerender
277+
value={page.id}
258278
onSelect={() => {
259279
closeCommandPanel();
260-
selectPage(page.id);
280+
if (action === "select") {
281+
selectPage(page.id);
282+
setActiveSidebarPanel("auto");
283+
$editingPageId.set(undefined);
284+
}
285+
if (action === "settings") {
286+
selectPage(page.id);
287+
setActiveSidebarPanel("pages");
288+
$editingPageId.set(page.id);
289+
}
261290
}}
262291
>
263-
<CommandIcon></CommandIcon>
264292
<Text variant="labelsTitleCase">{page.name}</Text>
265293
</CommandItem>
266294
))}
@@ -288,38 +316,41 @@ const CommandDialogContent = () => {
288316
}
289317
const groups = mapGroupBy(matches, (match) => match.type);
290318
return (
291-
<Command shouldFilter={false}>
319+
<>
292320
<CommandInput value={search} onValueChange={setSearch} />
293321
<Flex direction="column" css={{ maxHeight: 300 }}>
294322
<ScrollArea>
295323
<CommandList>
296324
{Array.from(groups).map(([group, matches]) => {
297325
if (group === "component") {
298326
return (
299-
<ComponentsGroup
327+
<ComponentGroup
300328
key={group}
301329
options={matches as ComponentOption[]}
302330
/>
303331
);
304332
}
305333
if (group === "breakpoint") {
306334
return (
307-
<BreakpointsGroup
335+
<BreakpointGroup
308336
key={group}
309337
options={matches as BreakpointOption[]}
310338
/>
311339
);
312340
}
313341
if (group === "page") {
314342
return (
315-
<PagesGroup key={group} options={matches as PageOption[]} />
343+
<PageGroup key={group} options={matches as PageOption[]} />
316344
);
317345
}
346+
group satisfies never;
318347
})}
319348
</CommandList>
320349
</ScrollArea>
321350
</Flex>
322-
</Command>
351+
<Separator />
352+
<CommandFooter />
353+
</>
323354
);
324355
};
325356

@@ -330,7 +361,9 @@ export const CommandPanel = () => {
330361
open={isOpen}
331362
onOpenChange={() => closeCommandPanel({ restoreFocus: true })}
332363
>
333-
<CommandDialogContent />
364+
<Command shouldFilter={false}>
365+
<CommandDialogContent />
366+
</Command>
334367
</CommandDialog>
335368
);
336369
};

apps/builder/app/builder/features/style-panel/sections/shared/input-popover.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ export const InputPopover = ({
139139
<Trigger />
140140
</PopoverTrigger>
141141
<PopoverContentStyled
142-
hideArrow
143142
sideOffset={-24}
144143
// prevent propagating click on input or combobox menu
145144
// and closing popover before applying changes

apps/builder/app/builder/features/topbar/menu/menu.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,13 @@ export const Menu = () => {
219219

220220
<DropdownMenuSeparator />
221221

222+
<DropdownMenuItem onSelect={() => emitCommand("openCommandPanel")}>
223+
Search & Commands
224+
<DropdownMenuItemRightSlot>
225+
<Kbd value={["cmd", "k"]} />
226+
</DropdownMenuItemRightSlot>
227+
</DropdownMenuItem>
228+
222229
<DropdownMenuItem
223230
onSelect={() => {
224231
window.open(

apps/builder/app/builder/shared/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
349349
},
350350

351351
{
352-
name: "search",
352+
name: "openCommandPanel",
353353
defaultHotkeys: ["meta+k", "ctrl+k"],
354354
handler: openCommandPanel,
355355
},
Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Meta, StoryFn } from "@storybook/react";
22
import {
33
Command as CommandComponent,
4+
CommandFooter,
45
CommandGroup,
56
CommandGroupHeading,
67
CommandIcon,
@@ -11,65 +12,93 @@ import {
1112
import { Text } from "./text";
1213
import { InfoCircleIcon } from "@webstudio-is/icons";
1314
import { Kbd } from "./kbd";
15+
import { Flex } from "./flex";
16+
import { Separator } from "./separator";
1417

1518
const meta: Meta = {
1619
title: "Library/Command",
1720
};
1821
export default meta;
1922

20-
export const Command: StoryFn = () => {
23+
const CommandContent = () => {
2124
return (
22-
<CommandComponent>
25+
<>
2326
<CommandInput />
2427
<CommandList>
2528
<CommandGroup
2629
heading={<CommandGroupHeading>Suggestions</CommandGroupHeading>}
30+
name="suggestions"
31+
actions={["select", "edit", "delete"]}
2732
>
2833
<CommandItem>
29-
<CommandIcon>
30-
<InfoCircleIcon />
31-
</CommandIcon>
32-
<Text variant="labelsTitleCase">Calendar</Text>
34+
<Flex gap={2}>
35+
<CommandIcon>
36+
<InfoCircleIcon />
37+
</CommandIcon>
38+
<Text variant="labelsTitleCase">Calendar</Text>
39+
</Flex>
3340
</CommandItem>
3441
<CommandItem>
35-
<CommandIcon>
36-
<InfoCircleIcon />
37-
</CommandIcon>
38-
<Text variant="labelsTitleCase">Search Emoji</Text>
42+
<Flex gap={2}>
43+
<CommandIcon>
44+
<InfoCircleIcon />
45+
</CommandIcon>
46+
<Text variant="labelsTitleCase">Search Emoji</Text>
47+
</Flex>
3948
</CommandItem>
4049
<CommandItem>
41-
<CommandIcon>
42-
<InfoCircleIcon />
43-
</CommandIcon>
44-
<Text variant="labelsTitleCase">Calculator</Text>
50+
<Flex gap={2}>
51+
<CommandIcon>
52+
<InfoCircleIcon />
53+
</CommandIcon>
54+
<Text variant="labelsTitleCase">Calculator</Text>
55+
</Flex>
4556
</CommandItem>
4657
</CommandGroup>
4758
<CommandGroup
4859
heading={<CommandGroupHeading>Settings</CommandGroupHeading>}
60+
name="settings"
61+
actions={["open"]}
4962
>
5063
<CommandItem>
51-
<CommandIcon>
52-
<InfoCircleIcon />
53-
</CommandIcon>
54-
<Text variant="labelsTitleCase">Profile</Text>
64+
<Flex gap={2}>
65+
<CommandIcon>
66+
<InfoCircleIcon />
67+
</CommandIcon>
68+
<Text variant="labelsTitleCase">Profile</Text>
69+
</Flex>
5570
<Kbd value={["cmd", "p"]} />
5671
</CommandItem>
5772
<CommandItem>
58-
<CommandIcon>
59-
<InfoCircleIcon />
60-
</CommandIcon>
61-
<Text variant="labelsTitleCase">Billing</Text>
73+
<Flex gap={2}>
74+
<CommandIcon>
75+
<InfoCircleIcon />
76+
</CommandIcon>
77+
<Text variant="labelsTitleCase">Billing</Text>
78+
</Flex>
6279
<Kbd value={["cmd", "b"]} />
6380
</CommandItem>
6481
<CommandItem>
65-
<CommandIcon>
66-
<InfoCircleIcon />
67-
</CommandIcon>
68-
<Text variant="labelsTitleCase">Settings</Text>
82+
<Flex gap={2}>
83+
<CommandIcon>
84+
<InfoCircleIcon />
85+
</CommandIcon>
86+
<Text variant="labelsTitleCase">Settings</Text>
87+
</Flex>
6988
<Kbd value={["cmd", "s"]} />
7089
</CommandItem>
7190
</CommandGroup>
7291
</CommandList>
92+
<Separator />
93+
<CommandFooter />
94+
</>
95+
);
96+
};
97+
98+
export const Command: StoryFn = () => {
99+
return (
100+
<CommandComponent>
101+
<CommandContent />
73102
</CommandComponent>
74103
);
75104
};

0 commit comments

Comments
 (0)