mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
feat: add component panel search improvements (#3933)
## Description - keyboard accessibility (arrow keys) - fix escape when search is not empty for components, fonts and images - add "a" shortcut same as wf ## 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: 5de6) - [ ] 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:
1 parent
ffccbf38b0
commit
480d628512
40 files changed
+377
-258
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
type WsComponentMeta,
|
||||
@@ -13,42 +13,217 @@ import {
|
||||
ListItem,
|
||||
SearchField,
|
||||
Separator,
|
||||
useSearchFieldKeys,
|
||||
findNextListItemIndex,
|
||||
Text,
|
||||
Box,
|
||||
Kbd,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { PlusIcon } from "@webstudio-is/icons";
|
||||
import { CollapsibleSection } from "~/builder/shared/collapsible-section";
|
||||
import type { TabContentProps } from "../../types";
|
||||
import { Header, CloseButton, Root } from "../../shared/panel";
|
||||
import {
|
||||
dragItemAttribute,
|
||||
elementToComponentName,
|
||||
useDraggable,
|
||||
} from "./use-draggable";
|
||||
import { dragItemAttribute, useDraggable } from "./use-draggable";
|
||||
import { MetaIcon } from "~/builder/shared/meta-icon";
|
||||
import { $registeredComponentMetas, $selectedPage } from "~/shared/nano-states";
|
||||
import { getMetaMaps } from "./get-meta-maps";
|
||||
import {
|
||||
getMetaMaps,
|
||||
type MetaByCategory,
|
||||
type ComponentNamesByMeta,
|
||||
} from "./get-meta-maps";
|
||||
import { getInstanceLabel } from "~/shared/instance-utils";
|
||||
import { isFeatureEnabled } from "@webstudio-is/feature-flags";
|
||||
import { insert } from "./insert";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { parseComponentName } from "@webstudio-is/sdk";
|
||||
|
||||
const matchComponents = (
|
||||
metas: Array<WsComponentMeta>,
|
||||
componentNamesByMeta: ComponentNamesByMeta,
|
||||
search: string
|
||||
) => {
|
||||
const getKey = (meta: WsComponentMeta) => {
|
||||
if (meta.label) {
|
||||
return meta.label.toLowerCase();
|
||||
}
|
||||
const component = componentNamesByMeta.get(meta);
|
||||
if (component) {
|
||||
const [_namespace, name] = parseComponentName(component);
|
||||
return name.toLowerCase();
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return matchSorter(metas, search, {
|
||||
keys: [getKey],
|
||||
});
|
||||
};
|
||||
|
||||
type Groups = Array<{
|
||||
category: Exclude<WsComponentMeta["category"], undefined> | "found";
|
||||
metas: Array<WsComponentMeta>;
|
||||
}>;
|
||||
|
||||
const filterAndGroupComponents = ({
|
||||
documentType = "html",
|
||||
metaByCategory,
|
||||
componentNamesByMeta,
|
||||
search,
|
||||
}: {
|
||||
documentType?: "html" | "xml";
|
||||
metaByCategory: MetaByCategory;
|
||||
componentNamesByMeta: ComponentNamesByMeta;
|
||||
search: string;
|
||||
}): Groups => {
|
||||
const categories = componentCategories.filter((category) => {
|
||||
if (category === "hidden") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only xml category is allowed for xml document type
|
||||
if (documentType === "xml") {
|
||||
return category === "xml" || category === "data";
|
||||
}
|
||||
// Hide xml category for non-xml document types
|
||||
if (category === "xml") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
isFeatureEnabled("internalComponents") === false &&
|
||||
category === "internal"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
let groups: Groups = categories.map((category) => {
|
||||
const metas = (metaByCategory.get(category) ?? []).filter((meta) => {
|
||||
const component = componentNamesByMeta.get(meta);
|
||||
|
||||
if (component === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (documentType === "xml" && meta.category === "data") {
|
||||
return component === "ws:collection";
|
||||
}
|
||||
|
||||
if (component === "RemixForm" && isFeatureEnabled("filters") === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (component === "ContentEmbed" && isFeatureEnabled("cms") === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return { category, metas };
|
||||
});
|
||||
|
||||
if (search.length !== 0) {
|
||||
let metas = groups.map((group) => group.metas).flat();
|
||||
metas = matchComponents(metas, componentNamesByMeta, search);
|
||||
groups = [{ category: "found", metas }];
|
||||
}
|
||||
|
||||
groups = groups.filter((group) => group.metas.length > 0);
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const findComponentIndex = (
|
||||
groups: Groups,
|
||||
componentNamesByMeta: ComponentNamesByMeta,
|
||||
selectedComponent?: string
|
||||
) => {
|
||||
if (selectedComponent === undefined) {
|
||||
return { index: -1, metas: groups[0].metas };
|
||||
}
|
||||
|
||||
for (const { metas } of groups) {
|
||||
const index = metas.findIndex((meta) => {
|
||||
return componentNamesByMeta.get(meta) === selectedComponent;
|
||||
});
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
return { index, metas };
|
||||
}
|
||||
|
||||
return { index: -1, metas: [] };
|
||||
};
|
||||
|
||||
export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => {
|
||||
const metaByComponentName = useStore($registeredComponentMetas);
|
||||
const selectedPage = useStore($selectedPage);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const documentType = selectedPage?.meta.documentType ?? "html";
|
||||
const [selectedComponent, setSelectedComponent] = useState<string>();
|
||||
|
||||
const handleInsert = (component: string) => {
|
||||
onSetActiveTab("none");
|
||||
insert(component);
|
||||
};
|
||||
|
||||
const resetSelectedComponent = () => {
|
||||
setSelectedComponent(undefined);
|
||||
};
|
||||
|
||||
const getSelectedComponent = () => {
|
||||
// When user didn't select a component but they have search input,
|
||||
// we want to always have the first component selected, so that user can just hit enter.
|
||||
if (selectedComponent === undefined && searchFieldProps.value) {
|
||||
return componentNamesByMeta.get(groups[0].metas[0]);
|
||||
}
|
||||
return selectedComponent;
|
||||
};
|
||||
|
||||
const searchFieldProps = useSearchFieldKeys({
|
||||
onChange: resetSelectedComponent,
|
||||
onCancel: resetSelectedComponent,
|
||||
onMove({ direction }) {
|
||||
if (direction === "current") {
|
||||
const component = getSelectedComponent();
|
||||
if (component !== undefined) {
|
||||
handleInsert(component);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { index, metas } = findComponentIndex(
|
||||
groups,
|
||||
componentNamesByMeta,
|
||||
selectedComponent
|
||||
);
|
||||
|
||||
const nextIndex = findNextListItemIndex(index, metas.length, direction);
|
||||
const nextComponent = componentNamesByMeta.get(metas[nextIndex]);
|
||||
|
||||
if (nextComponent) {
|
||||
setSelectedComponent(nextComponent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { metaByCategory, componentNamesByMeta } = useMemo(
|
||||
() => getMetaMaps(metaByComponentName),
|
||||
[metaByComponentName]
|
||||
);
|
||||
|
||||
const { dragCard, draggableContainerRef } = useDraggable({
|
||||
publish,
|
||||
metaByComponentName,
|
||||
});
|
||||
|
||||
const handleSearch = (e: FormEvent<HTMLInputElement>) => {
|
||||
setSearchText((e.target as HTMLInputElement).value);
|
||||
};
|
||||
const groups = filterAndGroupComponents({
|
||||
documentType: selectedPage?.meta.documentType,
|
||||
metaByCategory,
|
||||
componentNamesByMeta,
|
||||
search: searchFieldProps.value,
|
||||
});
|
||||
|
||||
return (
|
||||
<Root ref={draggableContainerRef}>
|
||||
@@ -56,128 +231,72 @@ export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => {
|
||||
title="Components"
|
||||
suffix={<CloseButton onClick={() => onSetActiveTab("none")} />}
|
||||
/>
|
||||
|
||||
<SearchField
|
||||
autoFocus
|
||||
value={searchText}
|
||||
title="Search"
|
||||
placeholder="Search..."
|
||||
onInput={handleSearch}
|
||||
onCancel={() => setSearchText("")}
|
||||
css={{
|
||||
mx: theme.spacing[9],
|
||||
mt: theme.spacing[9],
|
||||
mb: theme.spacing[9],
|
||||
}}
|
||||
/>
|
||||
<Box css={{ padding: theme.spacing[9] }}>
|
||||
<SearchField
|
||||
{...searchFieldProps}
|
||||
autoFocus
|
||||
placeholder="Find components"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea>
|
||||
{componentCategories
|
||||
.filter((category) => {
|
||||
if (category === "hidden") {
|
||||
return false;
|
||||
}
|
||||
{groups.map((group) => (
|
||||
<CollapsibleSection
|
||||
label={group.category}
|
||||
key={group.category}
|
||||
fullWidth
|
||||
>
|
||||
<List asChild>
|
||||
<Flex
|
||||
gap="2"
|
||||
wrap="wrap"
|
||||
css={{ px: theme.spacing[9], overflow: "auto" }}
|
||||
>
|
||||
{group.metas.map((meta: WsComponentMeta, index) => {
|
||||
const component = componentNamesByMeta.get(meta);
|
||||
|
||||
// Only xml category is allowed for xml document type
|
||||
if (documentType === "xml") {
|
||||
return category === "xml" || category === "data";
|
||||
}
|
||||
// Hide xml category for non-xml document types
|
||||
if (category === "xml") {
|
||||
return false;
|
||||
}
|
||||
if (component === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isFeatureEnabled("internalComponents") === false &&
|
||||
category === "internal"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((category) => {
|
||||
const meta = (metaByCategory.get(category) ?? []).filter(
|
||||
(meta: WsComponentMeta) => {
|
||||
if (documentType === "xml" && meta.category === "data") {
|
||||
return componentNamesByMeta.get(meta) === "ws:collection";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
const matchedMeta = matchSorter(meta, searchText, {
|
||||
keys: [(item) => componentNamesByMeta.get(item) || ""],
|
||||
});
|
||||
|
||||
return {
|
||||
category,
|
||||
meta: matchedMeta,
|
||||
};
|
||||
})
|
||||
.filter((category) => {
|
||||
return category.meta.length > 0;
|
||||
})
|
||||
.map((categoryGroup) => (
|
||||
<CollapsibleSection
|
||||
label={categoryGroup.category}
|
||||
key={categoryGroup.category}
|
||||
fullWidth
|
||||
>
|
||||
<List asChild>
|
||||
<Flex
|
||||
gap="2"
|
||||
wrap="wrap"
|
||||
css={{ px: theme.spacing[9], overflow: "auto" }}
|
||||
>
|
||||
{categoryGroup.meta.map((meta: WsComponentMeta, index) => {
|
||||
const component = componentNamesByMeta.get(meta);
|
||||
if (component === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isFeatureEnabled("filters") === false &&
|
||||
component === "RemixForm"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isFeatureEnabled("cms") === false &&
|
||||
component === "ContentEmbed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<ListItem
|
||||
asChild
|
||||
index={index}
|
||||
key={component}
|
||||
onSelect={(event) => {
|
||||
const component = elementToComponentName(
|
||||
event.target as HTMLElement,
|
||||
metaByComponentName
|
||||
);
|
||||
if (component) {
|
||||
onSetActiveTab("none");
|
||||
insert(component);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComponentCard
|
||||
{...{ [dragItemAttribute]: component }}
|
||||
label={getInstanceLabel({ component }, meta)}
|
||||
description={meta.description}
|
||||
icon={<MetaIcon size="auto" icon={meta.icon} />}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
{dragCard}
|
||||
</Flex>
|
||||
</List>
|
||||
</CollapsibleSection>
|
||||
))}
|
||||
return (
|
||||
<ListItem
|
||||
asChild
|
||||
state={
|
||||
component === getSelectedComponent()
|
||||
? "selected"
|
||||
: undefined
|
||||
}
|
||||
index={index}
|
||||
key={component}
|
||||
onSelect={() => {
|
||||
handleInsert(component);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setSelectedComponent(component);
|
||||
}}
|
||||
>
|
||||
<ComponentCard
|
||||
{...{ [dragItemAttribute]: component }}
|
||||
label={getInstanceLabel({ component }, meta)}
|
||||
description={meta.description}
|
||||
icon={<MetaIcon size="auto" icon={meta.icon} />}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
{dragCard}
|
||||
{group.metas.length === 0 && (
|
||||
<Flex grow justify="center" css={{ py: theme.spacing[10] }}>
|
||||
<Text>No matching component</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</List>
|
||||
</CollapsibleSection>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Root>
|
||||
);
|
||||
@@ -185,4 +304,9 @@ export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => {
|
||||
|
||||
export const Icon = PlusIcon;
|
||||
|
||||
export const label = "Components";
|
||||
export const label = (
|
||||
<Flex gap="1">
|
||||
<Text>Components</Text>
|
||||
<Kbd value={["(A)"]} />
|
||||
</Flex>
|
||||
);
|
||||
@@ -1,13 +1,17 @@
|
||||
import { type WsComponentMeta } from "@webstudio-is/react-sdk";
|
||||
|
||||
export type MetaByCategory = Map<
|
||||
WsComponentMeta["category"],
|
||||
Array<WsComponentMeta>
|
||||
>;
|
||||
|
||||
export type ComponentNamesByMeta = Map<WsComponentMeta, string>;
|
||||
|
||||
export const getMetaMaps = (
|
||||
metaByComponentName: Map<string, WsComponentMeta>
|
||||
) => {
|
||||
const metaByCategory: Map<
|
||||
WsComponentMeta["category"],
|
||||
Array<WsComponentMeta>
|
||||
> = new Map();
|
||||
const componentNamesByMeta: Map<WsComponentMeta, string> = new Map();
|
||||
const metaByCategory: MetaByCategory = new Map();
|
||||
const componentNamesByMeta: ComponentNamesByMeta = new Map();
|
||||
|
||||
for (const [name, meta] of metaByComponentName) {
|
||||
if (meta.category === undefined || meta.category === "hidden") {
|
||||
|
||||
@@ -68,7 +68,7 @@ const toCanvasCoordinates = (
|
||||
};
|
||||
};
|
||||
|
||||
export const elementToComponentName = (
|
||||
const elementToComponentName = (
|
||||
element: Element,
|
||||
metaByComponentName: Map<string, WsComponentMeta>
|
||||
) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { IconComponent } from "@webstudio-is/icons";
|
||||
type PanelExports = {
|
||||
TabContent: (props: TabContentProps) => ReactNode;
|
||||
Icon: IconComponent;
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
export const panels = new Map<TabName, PanelExports>([
|
||||
|
||||
@@ -162,7 +162,7 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
|
||||
([tabName, { Icon, label }]) => {
|
||||
return (
|
||||
<SidebarTabsTrigger
|
||||
key={label}
|
||||
key={tabName}
|
||||
label={label}
|
||||
value={tabName}
|
||||
onClick={() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
styled,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { forwardRef, type ComponentProps } from "react";
|
||||
import { forwardRef, type ComponentProps, type ReactNode } from "react";
|
||||
|
||||
export const SidebarTabs = styled(Tabs, {
|
||||
display: "flex",
|
||||
@@ -77,7 +77,7 @@ export const SidebarButton = forwardRef<
|
||||
|
||||
export const SidebarTabsTrigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ComponentProps<typeof TabsTrigger> & { label: string }
|
||||
ComponentProps<typeof TabsTrigger> & { label: ReactNode | string }
|
||||
>(({ label, children, ...props }, ref) => {
|
||||
return (
|
||||
<Tooltip side="right" content={label}>
|
||||
@@ -85,7 +85,7 @@ export const SidebarTabsTrigger = forwardRef<
|
||||
<TabsTrigger
|
||||
{...props}
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
aria-label={typeof label === "string" ? label : undefined}
|
||||
className={buttonStyle()}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./use-assets";
|
||||
export * from "./types";
|
||||
export * from "./separator";
|
||||
export * from "./use-search";
|
||||
export * from "./assets-shell";
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
type ChangeEventHandler,
|
||||
type KeyboardEventHandler,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type UseSearch = {
|
||||
onSelect: (direction: "next" | "previous" | "current") => void;
|
||||
};
|
||||
|
||||
export const useSearch = ({ onSelect }: UseSearch) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const handleKeyDown: KeyboardEventHandler = ({ code }) => {
|
||||
const keyMap = {
|
||||
ArrowUp: "previous",
|
||||
ArrowDown: "next",
|
||||
Enter: "current",
|
||||
} as const;
|
||||
const direction = keyMap[code as keyof typeof keyMap];
|
||||
if (direction !== undefined) {
|
||||
onSelect(direction);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const { value } = event.currentTarget;
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
return {
|
||||
value: search,
|
||||
onCancel: handleCancel,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
};
|
||||
};
|
||||
@@ -126,6 +126,14 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
|
||||
$breakpointsMenuView.set("initial");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "openComponentsPanel",
|
||||
defaultHotkeys: ["a"],
|
||||
handler: () => {
|
||||
$activeSidebarPanel.set("components");
|
||||
},
|
||||
disableHotkeyOnFormTags: true,
|
||||
},
|
||||
makeBreakpointCommand("selectBreakpoint1", 1),
|
||||
makeBreakpointCommand("selectBreakpoint2", 2),
|
||||
makeBreakpointCommand("selectBreakpoint3", 3),
|
||||
|
||||
@@ -2,15 +2,15 @@ import {
|
||||
DeprecatedList,
|
||||
DeprecatedListItem,
|
||||
useDeprecatedList,
|
||||
deprecatedFindNextListIndex,
|
||||
theme,
|
||||
useSearchFieldKeys,
|
||||
findNextListItemIndex,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
AssetsShell,
|
||||
deleteAssets,
|
||||
Separator,
|
||||
useAssets,
|
||||
useSearch,
|
||||
} from "~/builder/shared/assets";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMenu } from "./item-menu";
|
||||
@@ -34,13 +34,13 @@ const useLogic = ({
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const fontItems = useMemo(() => toItems(assetContainers), [assetContainers]);
|
||||
|
||||
const searchProps = useSearch({
|
||||
onSelect(direction) {
|
||||
const searchProps = useSearchFieldKeys({
|
||||
onMove({ direction }) {
|
||||
if (direction === "current") {
|
||||
handleChangeCurrent(selectedIndex);
|
||||
return;
|
||||
}
|
||||
const nextIndex = deprecatedFindNextListIndex(
|
||||
const nextIndex = findNextListItemIndex(
|
||||
selectedIndex,
|
||||
groupedItems.length,
|
||||
direction
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import {
|
||||
deprecatedFindNextListIndex,
|
||||
Grid,
|
||||
findNextListItemIndex,
|
||||
theme,
|
||||
useSearchFieldKeys,
|
||||
} from "@webstudio-is/design-system";
|
||||
import type { ImageAsset } from "@webstudio-is/sdk";
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
AssetsShell,
|
||||
type AssetContainer,
|
||||
useAssets,
|
||||
useSearch,
|
||||
deleteAssets,
|
||||
} from "../assets";
|
||||
import { ImageThumbnail } from "./image-thumbnail";
|
||||
@@ -30,8 +30,8 @@ const useLogic = ({
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
const searchProps = useSearch({
|
||||
onSelect(direction) {
|
||||
const searchProps = useSearchFieldKeys({
|
||||
onMove({ direction }) {
|
||||
if (direction === "current") {
|
||||
setSelectedIndex(selectedIndex);
|
||||
const assetContainer = filteredItems[selectedIndex];
|
||||
@@ -43,7 +43,7 @@ const useLogic = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextIndex = deprecatedFindNextListIndex(
|
||||
const nextIndex = findNextListItemIndex(
|
||||
selectedIndex,
|
||||
filteredItems.length,
|
||||
direction
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Text } from "../text";
|
||||
import { Flex } from "../flex";
|
||||
import { styled, theme } from "../../stitches.config";
|
||||
import { findNextListItemIndex } from "../primitives/list";
|
||||
|
||||
const ListBase = styled("ul", {
|
||||
display: "flex",
|
||||
@@ -92,27 +93,6 @@ export const DeprecatedListItem = forwardRef<
|
||||
});
|
||||
DeprecatedListItem.displayName = "DeprecatedListItem";
|
||||
|
||||
export const deprecatedFindNextListIndex = (
|
||||
currentIndex: number,
|
||||
total: number,
|
||||
direction: "next" | "previous"
|
||||
) => {
|
||||
const nextIndex =
|
||||
direction === "next"
|
||||
? currentIndex + 1
|
||||
: direction === "previous"
|
||||
? currentIndex - 1
|
||||
: currentIndex;
|
||||
|
||||
if (nextIndex < 0) {
|
||||
return total - 1;
|
||||
}
|
||||
if (nextIndex >= total) {
|
||||
return 0;
|
||||
}
|
||||
return nextIndex;
|
||||
};
|
||||
|
||||
type UseList<Item = unknown> = {
|
||||
items: Array<Item>;
|
||||
selectedIndex: number;
|
||||
@@ -155,7 +135,7 @@ export const useDeprecatedList = ({
|
||||
switch (event.code) {
|
||||
case "ArrowUp":
|
||||
case "ArrowDown": {
|
||||
const nextIndex = deprecatedFindNextListIndex(
|
||||
const nextIndex = findNextListItemIndex(
|
||||
selectedIndex,
|
||||
items.length,
|
||||
event.code === "ArrowUp" ? "previous" : "next"
|
||||
|
||||
@@ -22,7 +22,7 @@ export const Demo = () => {
|
||||
<ComponentCard
|
||||
icon={<BoxIcon />}
|
||||
label="Box"
|
||||
state="focus"
|
||||
state="selected"
|
||||
tabIndex={1}
|
||||
/>
|
||||
<ComponentCard
|
||||
|
||||
@@ -31,7 +31,7 @@ const cardStyle = css({
|
||||
background: theme.colors.backgroundPanel,
|
||||
color: theme.colors.foregroundDisabled,
|
||||
},
|
||||
"&:focus-visible, &[data-state=focus]": {
|
||||
"&:focus-visible, &[data-state=selected]": {
|
||||
outline: `2px solid ${theme.colors.borderFocus}`,
|
||||
outlineOffset: "-2px",
|
||||
},
|
||||
@@ -63,7 +63,7 @@ type ComponentCardProps = {
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: JSX.Element;
|
||||
state?: "hover" | "disabled" | "focus";
|
||||
state?: "hover" | "disabled" | "selected";
|
||||
} & ComponentProps<"div">;
|
||||
|
||||
export const ComponentCard = forwardRef<HTMLDivElement, ComponentCardProps>(
|
||||
|
||||
@@ -63,3 +63,24 @@ export const ListItem = ({
|
||||
};
|
||||
|
||||
ListItem.displayName = "ListItem";
|
||||
|
||||
export const findNextListItemIndex = (
|
||||
currentIndex: number,
|
||||
total: number,
|
||||
direction: "next" | "previous"
|
||||
) => {
|
||||
const nextIndex =
|
||||
direction === "next"
|
||||
? currentIndex + 1
|
||||
: direction === "previous"
|
||||
? currentIndex - 1
|
||||
: currentIndex;
|
||||
|
||||
if (nextIndex < 0) {
|
||||
return total - 1;
|
||||
}
|
||||
if (nextIndex >= total) {
|
||||
return 0;
|
||||
}
|
||||
return nextIndex;
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
type ChangeEventHandler,
|
||||
type KeyboardEventHandler,
|
||||
} from "react";
|
||||
import {
|
||||
CrossCircledFilledIcon,
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
import { styled } from "../stitches.config";
|
||||
import { theme } from "../stitches.config";
|
||||
import { InputField } from "./input-field";
|
||||
import { IconButton } from "./icon-button";
|
||||
import { SmallIconButton } from "./small-icon-button";
|
||||
import { Flex } from "./flex";
|
||||
|
||||
const SearchIcon = styled(MagnifyingGlassIcon, {
|
||||
// need to center icon vertically
|
||||
@@ -22,8 +25,7 @@ const SearchIcon = styled(MagnifyingGlassIcon, {
|
||||
padding: theme.spacing[3],
|
||||
});
|
||||
|
||||
const AbortButton = styled(IconButton, {
|
||||
marginRight: theme.spacing[3],
|
||||
const AbortButton = styled(SmallIconButton, {
|
||||
variants: {
|
||||
hidden: {
|
||||
false: { visibility: "hidden" },
|
||||
@@ -48,6 +50,10 @@ const SearchFieldBase: ForwardRefRenderFunction<
|
||||
useEffect(() => {
|
||||
setValue(String(propsValue));
|
||||
}, [propsValue]);
|
||||
const handleCancel = () => {
|
||||
setValue("");
|
||||
onCancel?.();
|
||||
};
|
||||
return (
|
||||
<InputField
|
||||
{...rest}
|
||||
@@ -60,28 +66,24 @@ const SearchFieldBase: ForwardRefRenderFunction<
|
||||
inputRef={inputRef}
|
||||
prefix={<SearchIcon />}
|
||||
suffix={
|
||||
<AbortButton
|
||||
hidden={value.length > 0 ? "true" : "false"}
|
||||
aria-label="Reset search"
|
||||
title="Reset search"
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
setValue("");
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
<CrossCircledFilledIcon />
|
||||
</AbortButton>
|
||||
<Flex align="center" css={{ padding: theme.spacing[2] }}>
|
||||
<AbortButton
|
||||
hidden={value.length > 0 ? "true" : "false"}
|
||||
aria-label="Reset search"
|
||||
title="Reset search"
|
||||
tabIndex={-1}
|
||||
onClick={handleCancel}
|
||||
icon={<CrossCircledFilledIcon />}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
onChange?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Make sure we clear the search on esc and preserve the default browser behavior
|
||||
if (event.key === "Escape" && value.length !== 0) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
handleCancel();
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
}}
|
||||
@@ -90,3 +92,46 @@ const SearchFieldBase: ForwardRefRenderFunction<
|
||||
};
|
||||
|
||||
export const SearchField = forwardRef(SearchFieldBase);
|
||||
|
||||
type UseSearchFieldKeys = {
|
||||
onMove: (event: { direction: "next" | "previous" | "current" }) => void;
|
||||
onChange?: ComponentProps<typeof SearchFieldBase>["onChange"];
|
||||
onCancel?: ComponentProps<typeof SearchFieldBase>["onCancel"];
|
||||
};
|
||||
|
||||
export const useSearchFieldKeys = ({
|
||||
onMove,
|
||||
onChange,
|
||||
onCancel,
|
||||
}: UseSearchFieldKeys) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const handleKeyDown: KeyboardEventHandler = ({ code }) => {
|
||||
const keyMap = {
|
||||
ArrowUp: "previous",
|
||||
ArrowDown: "next",
|
||||
Enter: "current",
|
||||
} as const;
|
||||
const direction = keyMap[code as keyof typeof keyMap];
|
||||
if (direction !== undefined) {
|
||||
onMove({ direction });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const { value } = event.currentTarget;
|
||||
setSearch(value.trim());
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSearch("");
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
return {
|
||||
value: search,
|
||||
onCancel: handleCancel,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
};
|
||||
};
|
||||
@@ -53,7 +53,7 @@ export { AccessibleIcon } from "@radix-ui/react-accessible-icon";
|
||||
export * from "./components/toggle-group";
|
||||
export * from "./components/progress-radial";
|
||||
export * from "./components/progress";
|
||||
export { SearchField } from "./components/search-field";
|
||||
export { SearchField, useSearchFieldKeys } from "./components/search-field";
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
|
||||
export { Card } from "./components/card";
|
||||
export * from "./components/tooltip";
|
||||
|
||||
@@ -62,7 +62,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "text",
|
||||
type: "container",
|
||||
label: "Blockquote",
|
||||
description:
|
||||
"Use to style a quote from an external source like an article or book.",
|
||||
icon: BlockquoteIcon,
|
||||
|
||||
@@ -25,7 +25,6 @@ const presetStyle = {
|
||||
|
||||
export const meta: WsComponentMeta = {
|
||||
type: "container",
|
||||
label: "Body",
|
||||
icon: BodyIcon,
|
||||
states: defaultStates,
|
||||
presetStyle,
|
||||
|
||||
@@ -39,7 +39,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "general",
|
||||
type: "container",
|
||||
label: "Box",
|
||||
description:
|
||||
"A container for content. By default this is a Div, but the tag can be changed in settings.",
|
||||
icon: BoxIcon,
|
||||
|
||||
@@ -17,7 +17,6 @@ export const meta: WsComponentMeta = {
|
||||
category: "forms",
|
||||
type: "container",
|
||||
invalidAncestors: ["Button", "Link"],
|
||||
label: "Button",
|
||||
description:
|
||||
"Use a button to submit forms or trigger actions within a page. Do not use a button to navigate users to another resource or another page - that’s what a link is used for.",
|
||||
icon: ButtonElementIcon,
|
||||
|
||||
@@ -23,7 +23,6 @@ export const meta: WsComponentMeta = {
|
||||
category: "forms",
|
||||
invalidAncestors: ["Button", "Link"],
|
||||
type: "control",
|
||||
label: "Checkbox",
|
||||
description:
|
||||
"Use within a form to allow your users to toggle between checked and not checked. Group checkboxes by matching their “Name” properties. Unlike radios, any number of checkboxes in a group can be checked.",
|
||||
icon: CheckboxCheckedIcon,
|
||||
|
||||
@@ -42,7 +42,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "general",
|
||||
type: "embed",
|
||||
label: "Code Text",
|
||||
description:
|
||||
"Use this component when you want to display code as text on the page.",
|
||||
icon: CodeTextIcon,
|
||||
|
||||
@@ -47,7 +47,6 @@ const descendant = (label: string, tag: string): WsEmbedTemplate[number] => {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "data",
|
||||
type: "control",
|
||||
label: "Content Embed",
|
||||
description:
|
||||
"Content Embed allows styling of HTML, which can be provided via the Code property statically or loaded dynamically from any Resource, for example, from a CMS.",
|
||||
icon: ContentEmbedIcon,
|
||||
|
||||
@@ -20,7 +20,6 @@ export const meta: WsComponentMeta = {
|
||||
category: "forms",
|
||||
type: "container",
|
||||
invalidAncestors: ["Form", "Button", "Link"],
|
||||
label: "Form",
|
||||
description: "Create filters, surveys, searches and more.",
|
||||
icon: FormIcon,
|
||||
states: defaultStates,
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
|
||||
export const meta: WsComponentMeta = {
|
||||
type: "container",
|
||||
label: "Fragment",
|
||||
icon: "",
|
||||
stylable: false,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "text",
|
||||
type: "container",
|
||||
label: "Heading",
|
||||
description:
|
||||
"Use HTML headings to structure and organize content. Use the Tag property in settings to change the heading level (h1-h6).",
|
||||
icon: HeadingIcon,
|
||||
|
||||
@@ -37,7 +37,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "media",
|
||||
type: "embed",
|
||||
label: "Image",
|
||||
description:
|
||||
"Add an image asset to the page. Webstudio automatically converts images to WebP or AVIF format and makes them responsive for best performance.",
|
||||
icon: ImageIcon,
|
||||
|
||||
@@ -26,7 +26,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "general",
|
||||
type: "container",
|
||||
label: "Link",
|
||||
description:
|
||||
"Use a link to send your users to another page, section, or resource. Configure links in the Settings panel.",
|
||||
icon: LinkIcon,
|
||||
|
||||
@@ -17,7 +17,6 @@ export const meta: WsComponentMeta = {
|
||||
category: "general",
|
||||
type: "container",
|
||||
requiredAncestors: ["List"],
|
||||
label: "List Item",
|
||||
description: "Adds a new item to an existing list.",
|
||||
icon: ListItemIcon,
|
||||
states: defaultStates,
|
||||
|
||||
@@ -45,7 +45,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "general",
|
||||
type: "container",
|
||||
label: "List",
|
||||
description: "Groups content, like links in a menu or steps in a recipe.",
|
||||
icon: ListIcon,
|
||||
states: defaultStates,
|
||||
|
||||
@@ -59,7 +59,6 @@ const descendant = (label: string, tag: string): WsEmbedTemplate[number] => {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "data",
|
||||
type: "embed",
|
||||
label: "Markdown Embed",
|
||||
description: "Used to add markdown code to the page",
|
||||
icon: MarkdownEmbedIcon,
|
||||
presetStyle: {
|
||||
|
||||
@@ -29,7 +29,6 @@ export const meta: WsComponentMeta = {
|
||||
// @todo: requiredAncestors should be ["Select", "Optgroup", "Datalist"] but that gives unreadable error when adding Select onto Canvas
|
||||
requiredAncestors: ["Select"],
|
||||
type: "control",
|
||||
label: "Option",
|
||||
description:
|
||||
"An item within a drop-down menu that users can select as their chosen value.",
|
||||
icon: ItemIcon,
|
||||
|
||||
@@ -16,7 +16,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "text",
|
||||
type: "container",
|
||||
label: "Paragraph",
|
||||
description: "A container for multi-line text.",
|
||||
icon: TextAlignLeftIcon,
|
||||
invalidAncestors: ["Paragraph"],
|
||||
|
||||
@@ -23,7 +23,6 @@ export const meta: WsComponentMeta = {
|
||||
category: "forms",
|
||||
invalidAncestors: ["Button", "Link"],
|
||||
type: "container",
|
||||
label: "Select",
|
||||
description:
|
||||
"A drop-down menu for users to select a single option from a predefined list.",
|
||||
icon: SelectIcon,
|
||||
|
||||
@@ -43,7 +43,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "general",
|
||||
type: "embed",
|
||||
label: "Separator",
|
||||
description:
|
||||
"Used to visually divide sections of content, helping to improve readability and organization within a webpage.",
|
||||
icon: DashIcon,
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "general",
|
||||
type: "container",
|
||||
label: "Slot",
|
||||
description:
|
||||
"Slot is a container for content that you want to reference across the project. Changes made to a Slot's children will be reflected in all other instances of that Slot.",
|
||||
icon: SlotComponentIcon,
|
||||
|
||||
@@ -22,7 +22,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "text",
|
||||
type: "container",
|
||||
label: "Text",
|
||||
description:
|
||||
"A generic container for any text content that is not a heading or a link.",
|
||||
icon: TextIcon,
|
||||
|
||||
@@ -17,7 +17,6 @@ const presetStyle = {
|
||||
export const meta: WsComponentMeta = {
|
||||
category: "media",
|
||||
type: "container",
|
||||
label: "Vimeo",
|
||||
description:
|
||||
"Add a video to your page that is hosted on Vimeo. Paste a Vimeo URL and configure the video in the Settings tab.",
|
||||
order: 1,
|
||||
|
||||
@@ -13,7 +13,7 @@ export const meta: WsComponentMeta = {
|
||||
type: "container",
|
||||
icon: XmlIcon,
|
||||
stylable: false,
|
||||
description: "Xml Node",
|
||||
description: "XML Node",
|
||||
};
|
||||
|
||||
export const propsMeta: WsComponentPropsMeta = {
|
||||
|
||||
Reference in new issue
Block a user