Skip to content

Commit

Permalink
experimental: support history in address bar (#3377)
Browse files Browse the repository at this point in the history
Address bar need to fill every time user want to test the page. Here
added history suggestions which let users reuse peviously entered
params.

Had to implement own combobox-like behavior to support syggestions
universally on all inputs.

<img width="384" alt="Screenshot 2024-05-18 at 17 35 54"
src="https://github.com/webstudio-is/webstudio/assets/5635476/4f5add04-cfe9-4ba0-a824-821011bbdd81">
  • Loading branch information
TrySound authored May 18, 2024
1 parent 2fd7c6d commit 5e39b06
Show file tree
Hide file tree
Showing 3 changed files with 234 additions and 9 deletions.
20 changes: 19 additions & 1 deletion apps/builder/app/builder/features/address-bar.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import type { Meta, StoryFn } from "@storybook/react";
import { Box, Text, theme } from "@webstudio-is/design-system";
import { AddressBarPopover } from "./address-bar";
Expand All @@ -9,7 +10,9 @@ import {
$selectedPage,
$selectedPageId,
} from "~/shared/nano-states";
import { useStore } from "@nanostores/react";
import { registerContainers } from "~/shared/sync";

registerContainers();

$dataSources.set(
new Map([
Expand Down Expand Up @@ -77,6 +80,20 @@ const SystemInspect = () => {
);
};

const $selectedPageHistory = computed(
$selectedPage,
(page) => page?.history ?? []
);

const HistoryInspect = () => {
const history = useStore($selectedPageHistory);
return (
<Text variant="mono" css={{ whiteSpace: "pre" }}>
{JSON.stringify(history, null, 2)}
</Text>
);
};

export default {
title: "Builder/Address Bar",
component: AddressBarPopover,
Expand All @@ -94,5 +111,6 @@ export const AddressBar: StoryFn = () => (
<AddressBarPopover />
</Box>
<SystemInspect />
<HistoryInspect />
</>
);
222 changes: 214 additions & 8 deletions apps/builder/app/builder/features/address-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import { mergeRefs } from "@react-aria/utils";
import {
forwardRef,
useEffect,
useRef,
useState,
type ComponentProps,
type RefObject,
} from "react";
import { flushSync } from "react-dom";
import {
Flex,
InputField,
Expand All @@ -20,13 +23,16 @@ import {
PopoverPortal,
PopoverContent,
IconButton,
MenuItemButton,
MenuList,
} from "@webstudio-is/design-system";
import { CheckMarkIcon, CopyIcon, DynamicPageIcon } from "@webstudio-is/icons";
import {
findParentFolderByChildId,
ROOT_FOLDER_ID,
getPagePath,
type System,
findPageByIdOrPath,
} from "@webstudio-is/sdk";
import {
$dataSourceVariables,
Expand All @@ -38,8 +44,10 @@ import {
import {
compilePathnamePattern,
isPathnamePattern,
matchPathnamePattern,
tokenizePathnamePattern,
} from "~/builder/shared/url-pattern";
import { serverSyncStore } from "~/shared/sync";

const $selectedPagePath = computed([$selectedPage, $pages], (page, pages) => {
if (pages === undefined || page === undefined) {
Expand Down Expand Up @@ -67,6 +75,30 @@ const $selectedPagePathParams = computed(
}
);

const $selectedPageHistory = computed(
$selectedPage,
(page) => page?.history ?? []
);

/**
* put new path into the beginning of history
* and drop paths in the end when exceeded 20
*/
const savePathInHistory = (path: string, pageId: string) => {
serverSyncStore.createTransaction([$pages], (pages) => {
if (pages === undefined) {
return;
}
const page = findPageByIdOrPath(pageId, pages);
if (page === undefined) {
return;
}
const history = Array.from(page.history ?? []);
history.unshift(path);
page.history = Array.from(new Set(history)).slice(0, 20);
});
};

const useCopyUrl = (pageUrl: string) => {
const [copyState, setCopyState] = useState<"copy" | "copied">("copy");
// reset copied state after 2 seconds
Expand Down Expand Up @@ -101,9 +133,166 @@ const useCopyUrl = (pageUrl: string) => {
};
};

const AddressBar = forwardRef<HTMLFormElement, unknown>((_props, ref) => {
const moveSelection = (menu: HTMLElement, diff: number) => {
const options = Array.from(menu.querySelectorAll("[role=option]"));
const index = options.findIndex((element) => element.ariaSelected === "true");
const newIndex = Math.max(-1, Math.min(index + diff, options.length - 1));
if (index >= 0) {
options[index].ariaSelected = null;
}
if (newIndex >= 0) {
options[newIndex].ariaSelected = "true";
}
};

/**
* Suggestions are opened whenever user
* - types in input
* - focuses input
* - press arrow down or arrow up
*
* and closed when
* - input is lost focus
* - escape or enter are pressed
*
* option selection is managed by arrow up, arrow down and hover
*/
const Suggestions = ({
containerRef,
options,
onSelect,
}: {
containerRef: RefObject<HTMLFormElement>;
options: string[];
onSelect: (option: string) => void;
}) => {
const list = options;

const menuRef = useRef<HTMLDivElement>(null);
const [isListOpen, setIsListOpen] = useState(false);

useEffect(() => {
const container = containerRef.current;
if (container === null) {
return;
}
const handleInput = () => {
setIsListOpen(true);
};
let frameId: undefined | number;
const handleFocusIn = () => {
if (frameId) {
cancelAnimationFrame(frameId);
}
setIsListOpen(true);
};
const handleFocusOut = () => {
frameId = requestAnimationFrame(() => {
setIsListOpen(false);
});
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
// avoid moving cursor to the end
event.preventDefault();
// trigger menu with up and down like in chrome
if (menuRef.current === null) {
setIsListOpen(true);
return;
}
moveSelection(menuRef.current, +1);
}
if (event.key === "ArrowUp") {
// avoid moving cursor to the start
event.preventDefault();
if (menuRef.current === null) {
setIsListOpen(true);
return;
}
moveSelection(menuRef.current, -1);
}
if (event.key === "Escape" && menuRef.current) {
// avoid closing popovers and dialogs when list is open
event.stopPropagation();
setIsListOpen(false);
}
if (event.key === "Enter" && menuRef.current) {
const selected = menuRef.current?.querySelector(
"[role=option][aria-selected=true]"
);
if (selected instanceof HTMLElement) {
// avoid submitting form when item is selected
event.preventDefault();
selected.click();
}
}
};
container.addEventListener("input", handleInput);
container.addEventListener("focusin", handleFocusIn);
container.addEventListener("focusout", handleFocusOut);
container.addEventListener("keydown", handleKeyDown);
return () => {
container.removeEventListener("input", handleInput);
container.removeEventListener("focusin", handleFocusIn);
container.removeEventListener("focusout", handleFocusOut);
container.removeEventListener("keydown", handleKeyDown);
};
}, [containerRef]);

if (isListOpen === false || list.length === 0) {
return;
}
return (
<MenuList
ref={menuRef}
role="listbox"
css={{
position: "absolute",
left: 0,
top: "calc(100% + 4px)",
minWidth: "100%",
}}
// close after selecting option
onClick={() => setIsListOpen(false)}
>
{list.map((option) => (
<MenuItemButton
key={option}
type="button"
role="option"
tabIndex={-1}
css={{ textTransform: "none", whiteSpace: "nowrap" }}
onMouseEnter={(event) => {
// select option on hover
const options =
menuRef.current?.querySelectorAll("[role=option]") ?? [];
for (const element of options) {
if (element.ariaSelected === "true") {
element.ariaSelected = null;
}
if (element === event.currentTarget) {
element.ariaSelected = "true";
}
}
}}
onClick={() => onSelect(option)}
>
{option}
</MenuItemButton>
))}
</MenuList>
);
};

const AddressBar = forwardRef<
HTMLFormElement,
{
onSubmit: () => void;
}
>(({ onSubmit }, ref) => {
const publishedOrigin = useStore($publishedOrigin);
const path = useStore($selectedPagePath);
const history = useStore($selectedPageHistory);
const [pathParams, setPathParams] = useState(
() => $selectedPagePathParams.get() ?? {}
);
Expand All @@ -129,9 +318,12 @@ const AddressBar = forwardRef<HTMLFormElement, unknown>((_props, ref) => {
}
}

const containerRef = useRef<HTMLFormElement>(null);

return (
<form
ref={ref}
ref={mergeRefs(ref, containerRef)}
style={{ position: "relative" }}
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
Expand All @@ -145,13 +337,29 @@ const AddressBar = forwardRef<HTMLFormElement, unknown>((_props, ref) => {
}
}
const page = $selectedPage.get();
if (page) {
updateSystem(page, { params: newParams });
if (page === undefined) {
return;
}
updateSystem(page, { params: newParams });
const compiledPath = compilePathnamePattern(tokens, newParams);
savePathInHistory(compiledPath, page.id);
if (errors.size === 0) {
onSubmit();
}
}}
>
{/* submit is not triggered when press enter on input without submit button */}
<button style={{ display: "none" }}>submit</button>
<Suggestions
containerRef={containerRef}
options={history}
onSelect={(option) => {
flushSync(() => {
setPathParams(matchPathnamePattern(path, option) ?? {});
});
containerRef.current?.requestSubmit();
}}
/>
<InputErrorsTooltip errors={Array.from(errors.values())}>
<Flex gap={1} css={{ padding: theme.spacing[5] }}>
<Flex align="center" gap={1} css={textVariants.mono}>
Expand Down Expand Up @@ -219,10 +427,8 @@ export const AddressBarPopover = () => {
<Popover
open={isOpen}
onOpenChange={(newIsOpen) => {
formRef.current?.requestSubmit();
setIsOpen(newIsOpen);
if (newIsOpen === false) {
formRef.current?.requestSubmit();
}
}}
>
<PopoverTrigger asChild>
Expand All @@ -237,7 +443,7 @@ export const AddressBarPopover = () => {
collisionPadding={4}
align="start"
>
<AddressBar ref={formRef} />
<AddressBar ref={formRef} onSubmit={() => setIsOpen(false)} />
</PopoverContent>
</PopoverPortal>
</Popover>
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/schema/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const commonPageFields = {
id: PageId,
name: PageName,
title: PageTitle,
history: z.optional(z.array(z.string())),
meta: z.object({
description: z.string().optional(),
title: z.string().optional(),
Expand Down

0 comments on commit 5e39b06

Please sign in to comment.