) => {
+ const input = event.currentTarget;
+ const value = input.value;
+ const newValue = allowEmpty && value === selected ? null : value;
+ onChange?.(newValue, event);
+ input.blur();
+ },
+ [selected, allowEmpty, onChange],
+ );
+
+ return (
+
+ {options.map(({ id, value, label, icon, disabled: optionDisabled }) => {
+ const isChecked = selected === value;
+ const isDisabled = wrapperDisabled || (optionDisabled ?? false);
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export const RadioButtons = memo(RadioButtonsInner);
diff --git a/frontend/packages/ui/src/lib/layout/TabSwitcher.module.scss b/frontend/packages/ui/src/lib/layout/TabSwitcher.module.scss
new file mode 100644
index 0000000000..97eb289af6
--- /dev/null
+++ b/frontend/packages/ui/src/lib/layout/TabSwitcher.module.scss
@@ -0,0 +1,122 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+@use "../_ds/_sizes.scss" as *;
+@use "../_ds/_borders.scss" as *;
+@use "../_ds/typography.scss" as t;
+
+.tabs {
+ --tabs-bg-color: var(--color-background-secondary);
+
+ display: grid;
+ grid-template-rows: auto 1fr;
+}
+
+.padding-wrapper {
+ padding-inline: var(--tabs-nav-padding-inline-start, 0) var(--tabs-nav-padding-inline-end, 0);
+ padding-block: var(--tabs-nav-padding-block-start, 0) var(--tabs-nav-padding-block-end, 0);
+}
+
+// TAB NAV
+.tab-nav {
+ display: grid;
+ gap: var(--sp-xxs);
+ width: 100%;
+ border-radius: $br-8;
+ padding: var(--sp-xxs);
+ background-color: var(--tabs-bg-color);
+}
+
+.tab-nav-start {
+ grid-template-columns: auto 1fr;
+}
+
+.tab-nav-end {
+ grid-template-columns: 1fr auto;
+}
+
+.tab-list {
+ display: grid;
+ grid-auto-flow: column;
+ gap: var(--sp-xxs);
+ width: 100%;
+
+ // Removing margin bottom from default ul
+ margin-block-end: 0;
+ border-radius: $br-8;
+}
+
+// TAB
+.tab {
+ --tabs-item-bg-color: var(--color-background-secondary);
+ --tabs-item-fg-color: var(--color-foreground-secondary);
+ --tabs-item-fg-color-hover: var(--color-foreground-primary);
+ --tabs-item-outline-color: none;
+
+ &:hover {
+ --tabs-item-fg-color: var(--tabs-item-fg-color-hover);
+ }
+
+ &:focus-visible {
+ --tabs-item-outline-color: var(--color-accent-primary);
+ }
+
+ appearance: none;
+ height: $sz-32;
+ border: none;
+ border-radius: $br-8;
+ outline: $b-1 solid var(--tabs-item-outline-color);
+ display: grid;
+ grid-auto-flow: column;
+ align-items: center;
+ justify-content: center;
+ column-gap: var(--sp-xs);
+ background: var(--tabs-item-bg-color);
+ color: var(--tabs-item-fg-color);
+ padding: 0 var(--sp-m);
+ width: 100%;
+}
+
+.selected {
+ --tabs-item-bg-color: var(--color-background-quaternary);
+ --tabs-item-fg-color: var(--color-accent-primary);
+ --tabs-item-fg-color-hover: var(--color-accent-primary);
+}
+
+.tab-text {
+ @include t.use-typography("headline-small");
+
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ min-width: 0;
+}
+
+.tab-text-and-icon {
+ padding-inline: var(--sp-xxs);
+}
+
+.tab-panel {
+ --tab-panel-outline-color: none;
+
+ &:focus {
+ outline: none;
+ }
+
+ &:focus-visible {
+ --tab-panel-outline-color: var(--color-accent-primary);
+ }
+
+ display: grid;
+ width: 100%;
+ height: 100%;
+ outline: $b-1 solid var(--tab-panel-outline-color);
+}
+
+.scrollable-panel {
+ overflow-y: auto;
+}
diff --git a/frontend/packages/ui/src/lib/layout/TabSwitcher.spec.tsx b/frontend/packages/ui/src/lib/layout/TabSwitcher.spec.tsx
new file mode 100644
index 0000000000..418675605e
--- /dev/null
+++ b/frontend/packages/ui/src/lib/layout/TabSwitcher.spec.tsx
@@ -0,0 +1,199 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { render, fireEvent } from "@testing-library/react";
+import { TabSwitcher } from "./TabSwitcher";
+import type { TabItem } from "./TabSwitcher";
+
+const tabs: TabItem[] = [
+ { id: "tab-code", label: "Code" },
+ { id: "tab-design", label: "Design" },
+ { id: "tab-menu", label: "Menu" },
+];
+
+describe("TabSwitcher", () => {
+ it("should render successfully", () => {
+ const { baseElement } = render(
+ ,
+ );
+ expect(baseElement).toBeTruthy();
+ });
+
+ it("should render all tab buttons", () => {
+ const { getByRole, getAllByRole } = render(
+ ,
+ );
+ const tablist = getByRole("tablist");
+ expect(tablist).toBeTruthy();
+ const tabButtons = getAllByRole("tab");
+ expect(tabButtons).toHaveLength(3);
+ });
+
+ it("should mark the selected tab as aria-selected", () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ const tabButtons = getAllByRole("tab");
+ const designTab = tabButtons.find(
+ (btn) => btn.textContent?.trim() === "Design",
+ ) as HTMLButtonElement;
+ expect(designTab.getAttribute("aria-selected")).toBe("true");
+ });
+
+ it("should mark other tabs as aria-selected=false", () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ const tabButtons = getAllByRole("tab");
+ const codeTab = tabButtons.find(
+ (btn) => btn.textContent?.trim() === "Code",
+ ) as HTMLButtonElement;
+ expect(codeTab.getAttribute("aria-selected")).toBe("false");
+ });
+
+ it("should call onTabChange when a tab is clicked", () => {
+ const handleChange = vi.fn();
+ const { getAllByRole } = render(
+ ,
+ );
+ const tabButtons = getAllByRole("tab");
+ const designTab = tabButtons.find(
+ (btn) => btn.textContent?.trim() === "Design",
+ ) as HTMLButtonElement;
+ fireEvent.click(designTab);
+ expect(handleChange).toHaveBeenCalledWith("tab-design");
+ });
+
+ it("should render a tabpanel", () => {
+ const { getByRole } = render(
+
+ Content
+ ,
+ );
+ const panel = getByRole("tabpanel");
+ expect(panel).toBeTruthy();
+ });
+
+ it("should render children inside the tab panel", () => {
+ const { getByText } = render(
+
+ Tab content here
+ ,
+ );
+ expect(getByText("Tab content here")).toBeTruthy();
+ });
+
+ it("should navigate with ArrowRight key", () => {
+ const handleChange = vi.fn();
+ const { getByRole } = render(
+ ,
+ );
+ const tablist = getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "ArrowRight" });
+ expect(handleChange).toHaveBeenCalledWith("tab-design");
+ });
+
+ it("should navigate with ArrowLeft key", () => {
+ const handleChange = vi.fn();
+ const { getByRole } = render(
+ ,
+ );
+ const tablist = getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "ArrowLeft" });
+ // wraps around to last tab
+ expect(handleChange).toHaveBeenCalledWith("tab-menu");
+ });
+
+ it("should navigate with Home key", () => {
+ const handleChange = vi.fn();
+ const { getByRole } = render(
+ ,
+ );
+ const tablist = getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "Home" });
+ expect(handleChange).toHaveBeenCalledWith("tab-code");
+ });
+
+ it("should pass className to wrapper", () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild as HTMLElement;
+ expect(wrapper.getAttribute("class")).toContain("my-class");
+ });
+
+ it("should spread extra props onto wrapper div", () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild as HTMLElement;
+ expect(wrapper.getAttribute("data-testid")).toBe("wrapper");
+ });
+
+ it("should render action button in start position", () => {
+ const { container } = render(
+ Action}
+ />,
+ );
+ const nav = container.querySelector("nav") as HTMLElement;
+ const navClass = nav.getAttribute("class") ?? "";
+ expect(navClass).toContain("tab-nav-start");
+ });
+
+ it("should render action button in end position", () => {
+ const { container } = render(
+ Action}
+ />,
+ );
+ const nav = container.querySelector("nav") as HTMLElement;
+ const navClass = nav.getAttribute("class") ?? "";
+ expect(navClass).toContain("tab-nav-end");
+ });
+
+ it("should set aria-labelledby on tabpanel to selected tab id", () => {
+ const { getByRole } = render(
+ ,
+ );
+ const panel = getByRole("tabpanel");
+ expect(panel.getAttribute("aria-labelledby")).toBe("tab-code");
+ });
+});
diff --git a/frontend/packages/ui/src/lib/layout/TabSwitcher.stories.tsx b/frontend/packages/ui/src/lib/layout/TabSwitcher.stories.tsx
new file mode 100644
index 0000000000..8c1059b480
--- /dev/null
+++ b/frontend/packages/ui/src/lib/layout/TabSwitcher.stories.tsx
@@ -0,0 +1,148 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+import { TabSwitcher } from "./TabSwitcher";
+import type { TabItem, TabSwitcherProps } from "./TabSwitcher";
+
+interface TabWithContent extends TabItem {
+ content: React.ReactNode;
+}
+
+function StatefulTabSwitcher({
+ tabs,
+ defaultSelected,
+ ...props
+}: Omit & {
+ tabs: TabWithContent[];
+ defaultSelected?: string;
+}) {
+ const [selected, setSelected] = useState(
+ defaultSelected ?? tabs[0]?.id ?? "",
+ );
+ const navTabs: TabItem[] = tabs.map(({ content: _c, ...item }) => item);
+ const contentMap = Object.fromEntries(tabs.map((t) => [t.id, t.content]));
+
+ return (
+
+ {contentMap[selected]}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Story data
+// ---------------------------------------------------------------------------
+
+const defaultTabs: TabWithContent[] = [
+ { id: "tab-code", label: "Code", content: Lorem Ipsum
},
+ { id: "tab-design", label: "Design", content: Dolor sit amet
},
+ {
+ id: "tab-menu",
+ label: "Menu",
+ content: Consectetur adipiscing elit
,
+ },
+];
+
+const iconTabs: TabWithContent[] = [
+ {
+ id: "tab-code",
+ "aria-label": "Code",
+ icon: "fill-content",
+ content: Lorem Ipsum
,
+ },
+ {
+ id: "tab-design",
+ "aria-label": "Design",
+ icon: "pentool",
+ content: Dolor sit amet
,
+ },
+ {
+ id: "tab-menu",
+ "aria-label": "Menu",
+ icon: "mask",
+ content: Consectetur adipiscing elit
,
+ },
+];
+
+const iconAndTextTabs: TabWithContent[] = [
+ {
+ id: "tab-code",
+ label: "Code",
+ icon: "fill-content",
+ content: Lorem Ipsum
,
+ },
+ {
+ id: "tab-design",
+ label: "Design",
+ icon: "pentool",
+ content: Dolor sit amet
,
+ },
+ {
+ id: "tab-menu",
+ label: "Menu",
+ icon: "mask",
+ content: Consectetur adipiscing elit
,
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Meta
+// ---------------------------------------------------------------------------
+
+const meta = {
+ title: "Layout/Tab Switcher",
+ component: StatefulTabSwitcher,
+ args: {
+ tabs: defaultTabs,
+ defaultSelected: "tab-code",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const WithIcons: Story = {
+ args: {
+ tabs: iconTabs,
+ },
+};
+
+export const WithIconsAndText: Story = {
+ args: {
+ tabs: iconAndTextTabs,
+ },
+};
+
+export const WithActionButtonStart: Story = {
+ args: {
+ actionButtonPosition: "start",
+ actionButton: (
+
+ ),
+ },
+};
+
+export const WithActionButtonEnd: Story = {
+ args: {
+ actionButtonPosition: "end",
+ actionButton: (
+
+ ),
+ },
+};
diff --git a/frontend/packages/ui/src/lib/layout/TabSwitcher.tsx b/frontend/packages/ui/src/lib/layout/TabSwitcher.tsx
new file mode 100644
index 0000000000..4db9049c18
--- /dev/null
+++ b/frontend/packages/ui/src/lib/layout/TabSwitcher.tsx
@@ -0,0 +1,250 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import {
+ type ComponentPropsWithRef,
+ type ReactNode,
+ memo,
+ useCallback,
+ useRef,
+} from "react";
+import clsx from "clsx";
+import { Icon } from "../foundations/assets/Icon";
+import type { IconId } from "../foundations/assets/Icon";
+import styles from "./TabSwitcher.module.scss";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface TabItem {
+ /** Unique tab identifier. Also used as the `id` and `aria-labelledby` value. */
+ id: string;
+ /** Visible label text. Either `label` or `aria-label` must be provided. */
+ label?: string;
+ /** Accessible label (for icon-only tabs). */
+ "aria-label"?: string;
+ /** Icon id. When provided, an icon is shown alongside or instead of the label. */
+ icon?: IconId;
+}
+
+export interface TabSwitcherProps extends ComponentPropsWithRef<"div"> {
+ /** Ordered list of tab definitions. Must have at least one item. */
+ tabs: TabItem[];
+ /** Id of the currently selected tab. */
+ selected: string;
+ /** Called when the user selects a different tab. */
+ onTabChange: (id: string) => void;
+ /** Element rendered alongside the tab nav bar. */
+ actionButton?: ReactNode;
+ /** Position of the action button relative to the tab list. */
+ actionButtonPosition?: "start" | "end";
+ /** When true, the tab panel scrolls vertically if its content overflows. */
+ scrollablePanel?: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Private: Tab button
+// ---------------------------------------------------------------------------
+
+interface TabProps {
+ id: string;
+ label?: string;
+ "aria-label"?: string;
+ icon?: IconId;
+ selected: boolean;
+ onClick: (event: React.MouseEvent) => void;
+ tabRef: (node: HTMLButtonElement | null) => void;
+}
+
+function Tab({
+ id,
+ label,
+ "aria-label": ariaLabel,
+ icon,
+ selected,
+ onClick,
+ tabRef,
+}: TabProps) {
+ return (
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Private: Tab nav
+// ---------------------------------------------------------------------------
+
+interface TabNavProps {
+ tabs: TabItem[];
+ selected: string;
+ actionButton?: ReactNode;
+ actionButtonPosition?: "start" | "end";
+ onTabClick: (event: React.MouseEvent) => void;
+ onKeyDown: (event: React.KeyboardEvent) => void;
+ registerRef: (id: string, node: HTMLButtonElement | null) => void;
+}
+
+const TabNav = memo(function TabNav({
+ tabs,
+ selected,
+ actionButton,
+ actionButtonPosition,
+ onTabClick,
+ onKeyDown,
+ registerRef,
+}: TabNavProps) {
+ return (
+
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Main component
+// ---------------------------------------------------------------------------
+
+function TabSwitcherInner({
+ tabs,
+ selected,
+ onTabChange,
+ actionButton,
+ actionButtonPosition,
+ scrollablePanel = false,
+ className,
+ children,
+ ...rest
+}: TabSwitcherProps) {
+ // Map of tab id → button DOM node, for programmatic focus
+ const nodesRef = useRef>({});
+
+ const registerRef = useCallback(
+ (id: string, node: HTMLButtonElement | null) => {
+ if (node != null) {
+ nodesRef.current[id] = node;
+ } else {
+ delete nodesRef.current[id];
+ }
+ },
+ [],
+ );
+
+ const handleTabClick = useCallback(
+ (event: React.MouseEvent) => {
+ const id = event.currentTarget.dataset["id"];
+ if (id != null) onTabChange(id);
+ },
+ [onTabChange],
+ );
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ const len = tabs.length;
+ const currentIndex = tabs.findIndex((t) => t.id === selected);
+ let nextId: string | undefined;
+
+ if (event.key === "Home") {
+ nextId = tabs[0]?.id;
+ } else if (event.key === "ArrowLeft") {
+ nextId = tabs[(currentIndex - 1 + len) % len]?.id;
+ } else if (event.key === "ArrowRight") {
+ nextId = tabs[(currentIndex + 1) % len]?.id;
+ }
+
+ if (nextId != null) {
+ onTabChange(nextId);
+ nodesRef.current[nextId]?.focus();
+ }
+ },
+ [tabs, selected, onTabChange],
+ );
+
+ return (
+
+ );
+}
+
+export const TabSwitcher = memo(TabSwitcherInner);
diff --git a/frontend/packages/ui/src/lib/product/EmptyState.module.scss b/frontend/packages/ui/src/lib/product/EmptyState.module.scss
new file mode 100644
index 0000000000..a0da881cfb
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/EmptyState.module.scss
@@ -0,0 +1,36 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+@use "../_ds/_sizes.scss" as *;
+@use "../_ds/typography.scss" as t;
+
+.group {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--sp-m);
+}
+
+.icon-wrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ inline-size: $sz-48;
+ block-size: $sz-48;
+}
+
+.icon {
+ color: var(--color-foreground-secondary);
+ inline-size: $sz-32;
+ block-size: $sz-32;
+}
+
+.text {
+ @include t.use-typography("body-small");
+
+ text-align: center;
+ color: var(--color-foreground-secondary);
+}
diff --git a/frontend/packages/ui/src/lib/product/EmptyState.spec.tsx b/frontend/packages/ui/src/lib/product/EmptyState.spec.tsx
new file mode 100644
index 0000000000..56b793065b
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/EmptyState.spec.tsx
@@ -0,0 +1,64 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { render } from "@testing-library/react";
+import { EmptyState } from "./EmptyState";
+
+describe("EmptyState", () => {
+ it("should render successfully", () => {
+ const { baseElement } = render();
+ expect(baseElement).toBeTruthy();
+ });
+
+ it("should render the provided text", () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText("Nothing to see here")).toBeTruthy();
+ });
+
+ it("should render an SVG icon", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeTruthy();
+ });
+
+ it("should pass className to wrapper", () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild as HTMLElement;
+ expect(wrapper.getAttribute("class")).toContain("my-class");
+ });
+
+ it("should spread extra props onto wrapper div", () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild as HTMLElement;
+ expect(wrapper.getAttribute("data-testid")).toBe("empty-state");
+ });
+
+ it("should apply the group class to the wrapper", () => {
+ const { container } = render();
+ const wrapper = container.firstElementChild as HTMLElement;
+ expect(wrapper.getAttribute("class")).toContain("group");
+ });
+
+ it("should render the icon inside an icon-wrapper div", () => {
+ const { container } = render();
+ const iconWrapper = container.querySelector("[class*='icon-wrapper']");
+ expect(iconWrapper).toBeTruthy();
+ });
+
+ it("should render text inside a div with text class", () => {
+ const { container } = render(
+ ,
+ );
+ const textDiv = container.querySelector("[class*='text']");
+ expect(textDiv?.textContent).toBe("Some text here");
+ });
+});
diff --git a/frontend/packages/ui/src/lib/product/EmptyState.stories.tsx b/frontend/packages/ui/src/lib/product/EmptyState.stories.tsx
new file mode 100644
index 0000000000..48bd7f75fb
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/EmptyState.stories.tsx
@@ -0,0 +1,29 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { EmptyState } from "./EmptyState";
+
+const meta = {
+ title: "Product/EmptyState",
+ component: EmptyState,
+ args: {
+ icon: "help",
+ text: "This is an empty state",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const WithDifferentIcon: Story = {
+ args: {
+ icon: "layers",
+ text: "No layers found",
+ },
+};
diff --git a/frontend/packages/ui/src/lib/product/EmptyState.tsx b/frontend/packages/ui/src/lib/product/EmptyState.tsx
new file mode 100644
index 0000000000..36be0b36c7
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/EmptyState.tsx
@@ -0,0 +1,39 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { type ComponentPropsWithRef, memo } from "react";
+import clsx from "clsx";
+import { Icon } from "../foundations/assets/Icon";
+import type { IconId } from "../foundations/assets/Icon";
+import styles from "./EmptyState.module.scss";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface EmptyStateProps extends ComponentPropsWithRef<"div"> {
+ /** Icon to display. Required. */
+ icon: IconId;
+ /** Descriptive text shown below the icon. Required. */
+ text: string;
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+function EmptyStateInner({ icon, text, className, ...rest }: EmptyStateProps) {
+ return (
+
+ );
+}
+
+export const EmptyState = memo(EmptyStateInner);