From 5e39b06d6b7d2069612c7e22cb756594b93a2627 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sat, 18 May 2024 23:22:03 +0300 Subject: [PATCH] experimental: support history in address bar (#3377) 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. Screenshot 2024-05-18 at 17 35 54 --- .../builder/features/address-bar.stories.tsx | 20 +- .../app/builder/features/address-bar.tsx | 222 +++++++++++++++++- packages/sdk/src/schema/pages.ts | 1 + 3 files changed, 234 insertions(+), 9 deletions(-) diff --git a/apps/builder/app/builder/features/address-bar.stories.tsx b/apps/builder/app/builder/features/address-bar.stories.tsx index a023c6a452c3..526f858083cd 100644 --- a/apps/builder/app/builder/features/address-bar.stories.tsx +++ b/apps/builder/app/builder/features/address-bar.stories.tsx @@ -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"; @@ -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([ @@ -77,6 +80,20 @@ const SystemInspect = () => { ); }; +const $selectedPageHistory = computed( + $selectedPage, + (page) => page?.history ?? [] +); + +const HistoryInspect = () => { + const history = useStore($selectedPageHistory); + return ( + + {JSON.stringify(history, null, 2)} + + ); +}; + export default { title: "Builder/Address Bar", component: AddressBarPopover, @@ -94,5 +111,6 @@ export const AddressBar: StoryFn = () => ( + ); diff --git a/apps/builder/app/builder/features/address-bar.tsx b/apps/builder/app/builder/features/address-bar.tsx index dbf40ea4bb62..f905baab1b68 100644 --- a/apps/builder/app/builder/features/address-bar.tsx +++ b/apps/builder/app/builder/features/address-bar.tsx @@ -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, @@ -20,6 +23,8 @@ import { PopoverPortal, PopoverContent, IconButton, + MenuItemButton, + MenuList, } from "@webstudio-is/design-system"; import { CheckMarkIcon, CopyIcon, DynamicPageIcon } from "@webstudio-is/icons"; import { @@ -27,6 +32,7 @@ import { ROOT_FOLDER_ID, getPagePath, type System, + findPageByIdOrPath, } from "@webstudio-is/sdk"; import { $dataSourceVariables, @@ -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) { @@ -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 @@ -101,9 +133,166 @@ const useCopyUrl = (pageUrl: string) => { }; }; -const AddressBar = forwardRef((_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; + options: string[]; + onSelect: (option: string) => void; +}) => { + const list = options; + + const menuRef = useRef(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 ( + setIsListOpen(false)} + > + {list.map((option) => ( + { + // 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} + + ))} + + ); +}; + +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() ?? {} ); @@ -129,9 +318,12 @@ const AddressBar = forwardRef((_props, ref) => { } } + const containerRef = useRef(null); + return (
{ event.preventDefault(); const formData = new FormData(event.currentTarget); @@ -145,13 +337,29 @@ const AddressBar = forwardRef((_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 */} + { + flushSync(() => { + setPathParams(matchPathnamePattern(path, option) ?? {}); + }); + containerRef.current?.requestSubmit(); + }} + /> @@ -219,10 +427,8 @@ export const AddressBarPopover = () => { { + formRef.current?.requestSubmit(); setIsOpen(newIsOpen); - if (newIsOpen === false) { - formRef.current?.requestSubmit(); - } }} > @@ -237,7 +443,7 @@ export const AddressBarPopover = () => { collisionPadding={4} align="start" > - + setIsOpen(false)} /> diff --git a/packages/sdk/src/schema/pages.ts b/packages/sdk/src/schema/pages.ts index 6d20aac2f5d6..8cafb6417462 100644 --- a/packages/sdk/src/schema/pages.ts +++ b/packages/sdk/src/schema/pages.ts @@ -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(),