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:
Oleg Isonen authored and GitHub committed 2024-08-15 02:21:40 +02:00
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,
};
};
+1 -1
View File
@@ -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 = {