diff --git a/apps/app/src/components/create-via-prompt-examples.tsx b/apps/app/src/components/create-via-prompt-examples.tsx index 340cbaaa7a4..befe8ac61c6 100644 --- a/apps/app/src/components/create-via-prompt-examples.tsx +++ b/apps/app/src/components/create-via-prompt-examples.tsx @@ -85,6 +85,7 @@ interface CreateWithTemplatesButtonProps { kind: CreateViaPromptKind; label: string; menuActions?: readonly ResourceCreateMenuAction[]; + compactWhenNarrow?: boolean; onCreate: (prompt?: string) => void; } @@ -92,6 +93,7 @@ export function CreateWithTemplatesButton({ kind, label, menuActions, + compactWhenNarrow, onCreate, }: CreateWithTemplatesButtonProps) { const { examples } = getCreateExamples(kind); @@ -116,6 +118,7 @@ export function CreateWithTemplatesButton({ templates={examples} templateGroups={templateGroups} menuActions={menuActions} + compactWhenNarrow={compactWhenNarrow} onCreate={onCreate} /> ); diff --git a/apps/app/src/components/plugin/PluginCreateButton.tsx b/apps/app/src/components/plugin/PluginCreateButton.tsx new file mode 100644 index 00000000000..706971fdda0 --- /dev/null +++ b/apps/app/src/components/plugin/PluginCreateButton.tsx @@ -0,0 +1,25 @@ +import { CreateWithTemplatesButton } from "@/components/create-via-prompt-examples"; + +export function PluginCreateButton({ + onCreate, + onInstallFromSource, +}: { + onCreate: (prompt?: string) => void; + onInstallFromSource: () => void; +}) { + return ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 231f5c0c5ad..f2c2c55342a 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -142,6 +142,9 @@ function installFetch(plugins: readonly unknown[] = [AUTOMATIONS_PLUGIN]) { if (url.pathname === "/api/v1/plugins") { return responseJson({ plugins }); } + if (url.pathname === "/api/v1/plugins/updates/check") { + return responseJson({ results: [] }); + } if (url.pathname === "/api/v1/plugin-catalog") { return responseJson({ catalog: { @@ -198,6 +201,87 @@ afterEach(() => { }); describe("PluginsOverview", () => { + it("checks updates on entering Installed, without rechecking on filters or focus", async () => { + installFetch(); + const requestCount = (path: string) => + vi + .mocked(fetch) + .mock.calls.filter(([input]) => String(input).endsWith(path)).length; + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + + + , + ); + await screen.findByRole("textbox", { name: "Search plugins" }); + expect(requestCount("/plugins/updates/check")).toBe(0); + fireEvent.click( + screen.getByRole("button", { name: "switch-to-installed" }), + ); + await screen.findByTestId("plugin-row-automations"); + await waitFor(() => { + expect(requestCount("/plugins/updates/check")).toBe(1); + expect(requestCount("/api/v1/plugins")).toBeGreaterThan(1); + }); + fireEvent.change( + screen.getByRole("textbox", { name: "Search installed plugins" }), + { target: { value: "Automations" } }, + ); + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + expect(requestCount("/plugins/updates/check")).toBe(1); + fireEvent.click(screen.getByRole("button", { name: "switch-to-browse" })); + fireEvent.click( + screen.getByRole("button", { name: "switch-to-installed" }), + ); + await waitFor(() => expect(requestCount("/plugins/updates/check")).toBe(2)); + }); + + it("clears only Source while retaining search, category, and sort", async () => { + installFetch(); + function LocationSearch() { + return {useLocation().search}; + } + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + + , + ); + fireEvent.pointerDown( + await screen.findByRole("button", { name: "Source: 1 selected" }), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear filter" })); + const params = new URLSearchParams( + screen.getByTestId("location-search").textContent ?? "", + ); + expect([...params]).toEqual([ + ["view", "installed"], + ["query", "Automations"], + ["category", "tasks-and-workflows"], + ["sort", "name"], + ["direction", "desc"], + ]); + expect( + screen + .getByRole("menuitem", { name: "Clear filter" }) + .getAttribute("aria-disabled"), + ).toBe("true"); + }); + it("opens on Browse and renders it before Installed", async () => { installFetch(); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); @@ -212,18 +296,15 @@ describe("PluginsOverview", () => { expect(await screen.findByText("GitHub")).toBeTruthy(); expect(screen.queryByRole("tab", { name: "Browse" })).toBeNull(); expect(screen.queryByRole("tab", { name: /Installed/ })).toBeNull(); - expect( - screen.getByRole("button", { name: "Create a plugin" }), - ).toBeTruthy(); + expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy(); const comboTrigger = screen.getByRole("button", { - name: "Create a plugin options", + name: "New plugin options", }); fireEvent.pointerDown(comboTrigger); expect( screen.getByRole("menuitem", { name: "Install from source" }), ).toBeTruthy(); fireEvent.keyDown(document, { key: "Escape" }); - expect(screen.queryByRole("button", { name: "New plugin" })).toBeNull(); const catalogRequests = () => vi.mocked(fetch).mock.calls.filter(([input]) => { @@ -259,7 +340,7 @@ describe("PluginsOverview", () => { await screen.findByText("GitHub"); const createPlugin = screen.getByRole("button", { - name: "Create a plugin", + name: "New plugin", }); fireEvent.click(createPlugin); @@ -282,7 +363,7 @@ describe("PluginsOverview", () => { ); }); - it("shows category filters only in Browse", async () => { + it("filters Browse by category", async () => { installFetch([ AUTOMATIONS_PLUGIN, { @@ -339,7 +420,46 @@ describe("PluginsOverview", () => { expect(screen.getByTestId("location-path").textContent).toBe("/"); }); - it("shows the Type filter on Installed instead of Category", async () => { + it("retains Direct install source filtering when sorting Installed", async () => { + installFetch([ + AUTOMATIONS_PLUGIN, + { + ...AUTOMATIONS_PLUGIN, + id: "local-notes", + name: "Local notes", + source: "path:/plugins/local-notes", + provenance: "direct", + publisherKey: null, + publisherLabel: null, + }, + ]); + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + , + ); + expect(await screen.findByText("Local notes")).toBeTruthy(); + expect(screen.queryByText("Automations")).toBeNull(); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Sort: Default" }), + ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Name" })); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByText("Automations")).toBeNull(); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Source: 1 selected" }), + ); + fireEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Direct install" }), + ); + fireEvent.keyDown(document, { key: "Escape" }); + expect(await screen.findByText("Automations")).toBeTruthy(); + }); + + it("shows the same category control on Installed", async () => { installFetch([AUTOMATIONS_PLUGIN]); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); render( @@ -354,7 +474,11 @@ describe("PluginsOverview", () => { expect(await screen.findByText("Automations")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Category" })).toBeNull(); - expect(screen.getByRole("button", { name: "Type" })).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "Filter plugins by category: All categories", + }), + ).toBeTruthy(); expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy(); }); @@ -381,7 +505,8 @@ describe("PluginsOverview", () => { ), ).toBeNull(); const search = screen.getByRole("textbox", { name: "Search plugins" }); - const toolbar = search.parentElement?.parentElement as HTMLElement; + const toolbar = search.closest("[data-resource-toolbar]"); + if (!toolbar) throw new Error("Missing collection toolbar"); const category = screen.getByRole("button", { name: "Filter plugins by category: All categories", }); @@ -582,62 +707,77 @@ describe("PluginsOverview", () => { expect(officialPills).toHaveLength(2); expect(screen.getAllByText("BB Community")).toHaveLength(1); - const sortTrigger = screen.getByRole("button", { - name: "Sort: Plugin name, ascending", - }); - expect(sortTrigger.querySelector('[data-icon="ArrowUpDown"]')).toBeTruthy(); + const sortTrigger = screen.getByRole("button", { name: "Sort: Default" }); fireEvent.pointerDown(sortTrigger); - fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" })); expect( + screen.getByRole("menuitemradio", { name: "Published" }), + ).toBeTruthy(); + expect( + screen + .getByRole("menuitemradio", { name: "Installs" }) + .getAttribute("aria-disabled"), + ).toBe("true"); + const rowIds = () => [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( (row) => row.getAttribute("data-testid"), - ), - ).toEqual([ - "plugin-row-enabled-official-zulu", - "plugin-row-enabled-official-alpha", + ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Name" })); + expect(rowIds()).toEqual([ "plugin-row-enabled-local-alpha", - "plugin-row-inactive-official", + "plugin-row-enabled-official-alpha", + "plugin-row-enabled-official-zulu", "plugin-row-inactive-local", + "plugin-row-inactive-official", ]); - - fireEvent.keyDown( - screen.getByRole("menu", { - name: "Sort: Plugin name, descending", - }), - { key: "Escape" }, - ); - fireEvent.click(screen.getByText("switch-to-browse")); - await screen.findByText("GitHub"); - fireEvent.click(screen.getByText("switch-to-installed")); - expect( - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ), - ).toEqual([ + fireEvent.click(screen.getByRole("menuitemradio", { name: "Name" })); + expect(rowIds()).toEqual([ + "plugin-row-inactive-official", + "plugin-row-inactive-local", "plugin-row-enabled-official-zulu", "plugin-row-enabled-official-alpha", "plugin-row-enabled-local-alpha", - "plugin-row-inactive-official", + ]); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); + expect(rowIds()).toEqual([ + "plugin-row-enabled-official-alpha", + "plugin-row-enabled-official-zulu", + "plugin-row-enabled-local-alpha", "plugin-row-inactive-local", + "plugin-row-inactive-official", ]); + expect( + screen + .getByRole("menuitem", { name: "Clear sort" }) + .getAttribute("aria-disabled"), + ).toBe("true"); }); - it("gives each publisher its own Type facet, separate from User", async () => { + it("groups path installs as Local while preserving marketplace categories", async () => { installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, + AUTOMATIONS_PLUGIN, { ...AUTOMATIONS_PLUGIN, - id: "catalog-one", - name: "Catalog One", - provenance: "catalog", - publisherKey: "bb-community", - publisherLabel: "BB Community", - catalogEntryId: "catalog-one", + id: "local-notes", + source: "path:/workspace/notes", + name: "Local Notes", + provenance: "direct", + publisherLabel: null, + categoryId: "memory-and-context", + category: "Memory & Context", }, { ...AUTOMATIONS_PLUGIN, - id: "direct-one", - name: "Direct One", + id: "local-other", + source: "path:/workspace/other", + name: "Other Plugin", + provenance: "direct", + publisherLabel: null, + }, + { + ...AUTOMATIONS_PLUGIN, + id: "uncategorized", + source: "git:https://github.com/example/uncategorized.git", + name: "Marketplace Plugin", provenance: "direct", publisherLabel: null, }, @@ -647,117 +787,34 @@ describe("PluginsOverview", () => { - - , ); - - await screen.findByText("Direct One"); - const rowIds = () => - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ); - - const typeTrigger = screen.getByRole("button", { name: "Type" }); - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - "plugin-row-direct-one", - ]); - fireEvent.pointerDown(typeTrigger); - expect(screen.queryByRole("menuitemcheckbox", { name: "All" })).toBeNull(); - + await screen.findByText("Local Notes"); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Official" }), - ); - await waitFor(() => { - expect(rowIds()).toEqual(["plugin-row-builtin-one"]); - }); - - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Community" }), - ); - await waitFor(() => { - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - ]); - }); - - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Official" }), + screen.getByRole("button", { + name: "Filter plugins by category: All categories", + }), ); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "BB Community" }), + await screen.findByRole("option", { name: /Local, 2 plugins/ }), ); - await waitFor(() => { - expect(rowIds()).toEqual(["plugin-row-direct-one"]); - }); - - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "User" })); - await waitFor(() => { - expect(rowIds()).toEqual([ - "plugin-row-builtin-one", - "plugin-row-catalog-one", - "plugin-row-direct-one", - ]); - }); - expect(screen.queryByText("No plugins match these filters.")).toBeNull(); - }); - - it("drops a Type selection whose facet no longer has any plugin", async () => { - installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, - { - ...AUTOMATIONS_PLUGIN, - id: "acme-one", - name: "Acme One", - provenance: "catalog", - publisherKey: "acme-plugins", - publisherLabel: "Acme Plugins", - catalogEntryId: "acme-one", - }, - ]); - const { wrapper: QueryClientWrapper, queryClient } = - createQueryClientTestHarness(); - render( - - - - - , - ); - - await screen.findByText("Acme One"); - fireEvent.pointerDown(screen.getByRole("button", { name: "Type" })); + expect(screen.queryByRole("option", { name: /Uncategorized/ })).toBeNull(); + expect(screen.getByText("Local Notes")).toBeTruthy(); + expect(screen.getByText("Other Plugin")).toBeTruthy(); + expect(screen.queryByText("Marketplace Plugin")).toBeNull(); + expect( + screen.queryByRole("option", { name: /Memory & Context/ }), + ).toBeNull(); + expect(screen.queryByText("Automations")).toBeNull(); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Acme Plugins" }), + screen.getByRole("option", { name: /Workflow management, 1 plugin/ }), ); - await waitFor(() => { - expect( - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ), - ).toEqual(["plugin-row-acme-one"]); - }); - - installFetch([ - { ...AUTOMATIONS_PLUGIN, id: "builtin-one", name: "Builtin One" }, - ]); - await act(async () => { - await queryClient.invalidateQueries(); - }); - - await waitFor(() => { - expect( - [...document.querySelectorAll('[data-testid^="plugin-row-"]')].map( - (row) => row.getAttribute("data-testid"), - ), - ).toEqual(["plugin-row-builtin-one"]); - }); - expect(screen.queryByText("No plugins match these filters.")).toBeNull(); + expect(screen.getByText("Automations")).toBeTruthy(); + expect(screen.queryByText("Marketplace Plugin")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + expect(screen.getByText("Other Plugin")).toBeTruthy(); + expect(screen.getByText("Marketplace Plugin")).toBeTruthy(); }); it("keeps disabled plugins installed regardless of provenance", async () => { diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 60caedae761..dbe26793ebf 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -1,5 +1,10 @@ +import { + pluginSourceFilterId, + pluginSourceFilterOptions, +} from "./plugin-provenance"; +import { usePluginCollectionParams } from "./management/usePluginCollectionParams"; import { useMemo, useState, type ReactNode } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { ResourceInfiniteScrollSentinel, useResourceInfiniteItems, @@ -9,12 +14,9 @@ import { ResourceCollectionPage, ResourceCollectionViewport, ResourceListState, - ResourceMultiSelectMenu, - ResourceSortMenu, - ResourceToolbar, } from "@bb/shared-ui/resource-list"; import { cn } from "@bb/shared-ui/lib/utils"; -import { CreateWithTemplatesButton } from "@/components/create-via-prompt-examples"; +import { PluginCreateButton } from "./PluginCreateButton"; import { CREATE_PLUGIN_PROMPT } from "@bb/client-core"; import { TOOLS_PAGE_BAND_CLASSES } from "@/components/tools/tools-navigation"; import { @@ -22,13 +24,19 @@ import { type AddPluginInitial, } from "@/components/plugin/management/AddPluginDialog"; import { BrowsePluginsTab } from "@/components/plugin/management/BrowsePluginsTab"; -import { CheckPluginUpdatesButton } from "@/components/plugin/management/CheckPluginUpdatesButton"; import { InstalledPluginsTab } from "@/components/plugin/management/InstalledPluginsTab"; import { PluginAuthorPage } from "@/components/plugin/management/PluginAuthorPage"; import { - pluginPublisherFilterId, - pluginPublisherFilterOptions, -} from "@/components/plugin/plugin-provenance"; + usePluginCatalogSearch, + usePluginUpdateCheck, +} from "@/hooks/queries/plugin-catalog-queries"; +import { installedPluginCatalogEntry } from "./management/installed-plugin-catalog"; +import { PluginCollectionToolbar } from "./management/PluginBrowseControls"; +import { + pluginCategoryFilterId, + pluginCategoryFilterOptions, + sortPluginEntries, +} from "./management/plugin-browse-discovery"; import { PLUGINS_INSTALLED_DESCRIPTION } from "@/components/plugin/plugins-collection-copy"; import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; import { @@ -44,7 +52,14 @@ export function PluginsOverview({ onOpenPlugin?: (pluginId: string, trigger: HTMLButtonElement) => void; } = {}) { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const { + searchParams, + query: installedQuery, + requestedSort, + sortDirection: installedSortDirection, + selectedCategories, + changeSearchParams, + } = usePluginCollectionParams(); const listQuery = usePluginList({ enabled: true }); const plugins = useMemo( () => listQuery.data?.plugins ?? [], @@ -52,78 +67,118 @@ export function PluginsOverview({ ); const activeMode = mode ?? (searchParams.get("view") === "installed" ? "installed" : "browse"); + usePluginUpdateCheck(null, { enabled: activeMode === "installed" }); const authorKey = searchParams.get("author"); - const [installedQuery, setInstalledQuery] = useState(""); - const [installedSortDirection, setInstalledSortDirection] = useState< - "asc" | "desc" - >("asc"); - const [typeFilters, setTypeFilters] = useState([]); - const typeFilterOptions = useMemo( - () => pluginPublisherFilterOptions(plugins), + const catalogQuery = usePluginCatalogSearch("", { + enabled: activeMode === "installed", + }); + const installedEntries = useMemo( + () => + plugins.map((plugin) => { + const entry = installedPluginCatalogEntry( + plugin, + catalogQuery.data?.entries ?? [], + ); + const isLocal = plugin.source.startsWith("path:"); + return { + plugin, + entryId: plugin.id, + displayName: plugin.name ?? plugin.id, + categoryId: isLocal + ? "local" + : (entry?.categoryId ?? plugin.categoryId), + category: isLocal ? "Local" : (entry?.category ?? plugin.category), + publishedAt: entry?.publishedAt, + installs: entry?.installs ?? null, + }; + }), + [plugins, catalogQuery.data?.entries], + ); + const sourceFilterOptions = useMemo( + () => pluginSourceFilterOptions(plugins), [plugins], ); - const activeTypeFilters = useMemo(() => { - const offered = new Set(typeFilterOptions.map((option) => option.id)); - return typeFilters.filter((value) => offered.has(value)); - }, [typeFilterOptions, typeFilters]); + const sourceFilters = searchParams.getAll("source"); + const activeSourceFilters = sourceFilters.filter((value) => + sourceFilterOptions.some((option) => option.id === value), + ); + const categoryOptions = useMemo( + () => pluginCategoryFilterOptions(installedEntries, selectedCategories), + [installedEntries, selectedCategories], + ); + const installsKnown = installedEntries.some( + (entry) => entry.installs !== null, + ); + const installedSort = + requestedSort === "most-installed" && !installsKnown ? null : requestedSort; const normalizedInstalledQuery = installedQuery.trim().toLowerCase(); const installedResetKey = [ normalizedInstalledQuery, + [...activeSourceFilters].sort().join(","), + installedSort, installedSortDirection, - [...activeTypeFilters].sort().join(","), + [...selectedCategories].sort().join(","), ].join("\u0000"); const [addDialog, setAddDialog] = useState<{ open: boolean; initial: AddPluginInitial | null; }>({ open: false, initial: null }); - const visiblePlugins = useMemo( - () => - plugins - .filter((plugin) => { - if ( - activeTypeFilters.length > 0 && - !activeTypeFilters.includes(pluginPublisherFilterId(plugin)) - ) { - return false; - } - if (normalizedInstalledQuery.length === 0) return true; - return [ - plugin.id, - plugin.name ?? "", - plugin.description ?? "", - plugin.version, - plugin.sourceDisplay, - ] - .join(" ") - .toLowerCase() - .includes(normalizedInstalledQuery); - }) - .sort((left, right) => { - const enabledResult = Number(!left.enabled) - Number(!right.enabled); - if (enabledResult !== 0) return enabledResult; - if (left.enabled) { - const leftPublisher = left.publisherLabel; - const rightPublisher = right.publisherLabel; - const publisherResult = - Number(leftPublisher === null) - Number(rightPublisher === null); - if (publisherResult !== 0) return publisherResult; - } - const result = (left.name ?? left.id).localeCompare( - right.name ?? right.id, - ); - if (result !== 0) { - return installedSortDirection === "asc" ? result : -result; - } - return left.id.localeCompare(right.id); - }), - [ - activeTypeFilters, - installedSortDirection, - normalizedInstalledQuery, - plugins, - ], - ); + const visiblePlugins = useMemo(() => { + const filtered = installedEntries.filter((entry) => { + if ( + activeSourceFilters.length > 0 && + !activeSourceFilters.includes(pluginSourceFilterId(entry.plugin)) + ) + return false; + if ( + selectedCategories.length > 0 && + !selectedCategories.includes(pluginCategoryFilterId(entry)) + ) + return false; + if (normalizedInstalledQuery.length === 0) return true; + const plugin = entry.plugin; + return [ + plugin.id, + plugin.name ?? "", + plugin.description ?? "", + plugin.version, + plugin.sourceDisplay, + ] + .join(" ") + .toLowerCase() + .includes(normalizedInstalledQuery); + }); + if (installedSort !== null) + return sortPluginEntries( + filtered, + installedSort, + installedSortDirection, + ).map((entry) => entry.plugin); + return filtered + .map((entry) => entry.plugin) + .sort((left, right) => { + const enabledResult = Number(!left.enabled) - Number(!right.enabled); + if (enabledResult !== 0) return enabledResult; + if (left.enabled) { + const publisherResult = + Number(left.publisherLabel === null) - + Number(right.publisherLabel === null); + if (publisherResult !== 0) return publisherResult; + } + return ( + (left.name ?? left.id).localeCompare(right.name ?? right.id) || + left.id.localeCompare(right.id) + ); + }); + }, [ + installedEntries, + activeSourceFilters, + selectedCategories, + normalizedInstalledQuery, + installedSort, + installedSortDirection, + ]); const installedList = useResourceInfiniteItems(visiblePlugins, { pageSize: RESOURCE_GRID_PAGE_SIZE, resetKey: installedResetKey, @@ -140,20 +195,10 @@ export function PluginsOverview({ }; const installedActions = ( - <> - setAddDialog({ open: true, initial: null }), - }, - ]} - onCreate={startCreatePlugin} - /> - + setAddDialog({ open: true, initial: null })} + /> ); const openPlugin = @@ -189,35 +234,25 @@ export function PluginsOverview({ scrollId="plugins-installed-results" bandClassName={TOOLS_PAGE_BAND_CLASSES} toolbar={ - - - - setInstalledSortDirection((current) => - current === "asc" ? "desc" : "asc", - ) - } - /> - {plugins.length > 0 ? : null} - - } + sourceFilter={{ + options: sourceFilterOptions, + selectedValues: activeSourceFilters, + onChange: (values) => + changeSearchParams((next) => { + next.delete("source"); + for (const value of values) next.append("source", value); + }), + }} /> } > @@ -236,7 +271,8 @@ export function PluginsOverview({ message={ normalizedInstalledQuery === "" ? "No plugins match these filters." - : activeTypeFilters.length > 0 + : selectedCategories.length > 0 || + activeSourceFilters.length > 0 ? `No plugins match "${installedQuery}" with these filters.` : `No plugins match "${installedQuery}"` } diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index f51be6e9f5c..03f9f4d0d64 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, useLocation } from "react-router-dom"; import type { @@ -9,6 +15,8 @@ import type { } from "@/hooks/queries/plugin-catalog-queries"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { BrowsePluginsTab } from "./BrowsePluginsTab"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { PLUGINS_BROWSE_DESCRIPTION } from "../plugins-collection-copy"; vi.mock("@/components/plugin/PluginNewThreadComposer", () => ({ PluginNewThreadComposer: ({ initialPrompt }: { initialPrompt?: string }) => ( @@ -136,6 +144,40 @@ afterEach(() => { }); describe("BrowsePluginsTab", () => { + it("puts the shared create action in the compact toolbar and keeps the page description", async () => { + stubCatalog({ entries: [MEMORY_ENTRY], collections: [] }); + const { wrapper } = createQueryClientTestHarness(); + render( + + + undefined} + onOpenPlugin={() => undefined} + onInstallFromSource={() => undefined} + /> + + + , + { wrapper }, + ); + const create = await screen.findByRole("button", { name: "New plugin" }); + expect(create.closest("[data-resource-toolbar]")).toBeTruthy(); + expect( + screen + .getByRole("button", { name: "New plugin options" }) + .closest("[data-resource-toolbar]"), + ).toBe(create.closest("[data-resource-toolbar]")); + expect( + screen.getAllByText(PLUGINS_BROWSE_DESCRIPTION).length, + ).toBeGreaterThan(0); + expect(screen.queryByRole("button", { name: "Plugin Guide" })).toBeNull(); + fireEvent.click(create); + expect(await screen.findByTestId("inline-composer")).toBeTruthy(); + expect(screen.getByTestId("location-search").textContent).toContain( + "query=Memory&view=create", + ); + }); + it("shows collection shelves before category shelves", async () => { renderBrowse({ entries: [ @@ -182,12 +224,17 @@ describe("BrowsePluginsTab", () => { name: "Search plugins", }); expect((search as HTMLInputElement).value).toBe("Mem"); + expect(screen.queryByTestId("plugin-browse-shelves")).toBeNull(); fireEvent.change(search, { target: { value: "Memory" } }); const params = new URLSearchParams( screen.getByTestId("location-search").textContent ?? "", ); expect(params.get("query")).toBe("Memory"); + fireEvent.change(search, { target: { value: "" } }); + await waitFor(() => + expect(screen.getByTestId("plugin-browse-shelves")).toBeTruthy(), + ); }); it("routes the card author name and preserves the Browse filters", async () => { @@ -218,7 +265,7 @@ describe("BrowsePluginsTab", () => { const trigger = await screen.findByRole("button", { name: "Filter plugins by category: Memory & Context, Security", }); - expect(trigger.textContent).toContain("2 categories"); + expect(screen.queryByTestId("plugin-browse-shelves")).toBeNull(); fireEvent.click(trigger); fireEvent.click( await screen.findByRole("option", { name: /Tasks & Workflows/u }), @@ -240,9 +287,11 @@ describe("BrowsePluginsTab", () => { "memory-and-context", "tasks-and-workflows", ]); + fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + expect(screen.getByTestId("plugin-browse-shelves")).toBeTruthy(); }); - it("orders category options by the shelf category order", async () => { + it("orders category options by shelf order and omits missing categories", async () => { renderBrowse({ entries: [ TASKS_ENTRY, @@ -282,7 +331,6 @@ describe("BrowsePluginsTab", () => { expect.stringContaining("Security"), expect.stringContaining("Tasks & Workflows"), expect.stringContaining("Unknown Category"), - expect.stringContaining("Uncategorized"), ]); }); @@ -296,12 +344,12 @@ describe("BrowsePluginsTab", () => { }); const sortTrigger = await screen.findByRole("button", { - name: "Sort: Featured", + name: "Sort: Default", }); fireEvent.pointerDown(sortTrigger); expect( screen - .getByRole("menuitemradio", { name: "Most installed" }) + .getByRole("menuitemradio", { name: "Installs" }) .getAttribute("aria-disabled"), ).toBe("true"); }); @@ -319,12 +367,10 @@ describe("BrowsePluginsTab", () => { "Open Tasks details", ]); const trigger = screen.getByRole("button", { - name: "Sort: Most installed, descending", + name: "Sort: Installs, descending", }); fireEvent.pointerDown(trigger); - fireEvent.click( - screen.getByRole("menuitemradio", { name: "Most installed" }), - ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Installs" })); expect(cardOrder()).toEqual([ "Open Security details", "Open Memory details", @@ -346,12 +392,10 @@ describe("BrowsePluginsTab", () => { "Open Security details", ]); const trigger = screen.getByRole("button", { - name: "Sort: Recently added, descending", + name: "Sort: Published, descending", }); fireEvent.pointerDown(trigger); - fireEvent.click( - screen.getByRole("menuitemradio", { name: "Recently added" }), - ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Published" })); expect(cardOrder()).toEqual([ "Open Memory details", "Open Tasks details", @@ -366,11 +410,11 @@ describe("BrowsePluginsTab", () => { ); const trigger = await screen.findByRole("button", { - name: "Sort: Most installed, descending", + name: "Sort: Installs, descending", }); expect(screen.queryByTestId("plugin-browse-shelves")).toBeNull(); fireEvent.pointerDown(trigger); - fireEvent.click(screen.getByRole("menuitemradio", { name: "Featured" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); expect(await screen.findByTestId("plugin-browse-shelves")).toBeTruthy(); const params = new URLSearchParams( screen.getByTestId("location-search").textContent ?? "", @@ -527,7 +571,7 @@ describe("BrowsePluginsTab", () => { expect( await screen.findByRole("button", { name: "Open Memory details" }), ).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Create a plugin" })); + fireEvent.click(screen.getByRole("button", { name: "New plugin" })); expect(await screen.findByText("Start from an example")).toBeTruthy(); expect(screen.getByText("Explore plugin capabilities")).toBeTruthy(); expect( @@ -541,9 +585,7 @@ describe("BrowsePluginsTab", () => { it("routes every create affordance into the inline composer", async () => { renderBrowse({ entries: [MEMORY_ENTRY], collections: [] }); - fireEvent.click( - await screen.findByRole("button", { name: "Create a plugin" }), - ); + fireEvent.click(await screen.findByRole("button", { name: "New plugin" })); expect((await screen.findByTestId("inline-composer")).textContent).toBe( "Create a new bb plugin that ", ); diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 7fc29372c76..ee8cbe27e54 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -1,12 +1,7 @@ +import { usePluginCollectionParams } from "./usePluginCollectionParams"; import { useEffect, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; -import { Button } from "@bb/shared-ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@bb/shared-ui/dropdown-menu"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { Icon } from "@bb/shared-ui/icon"; import bbLogoUrl from "../../../../../../assets/bb-logo.svg"; import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; @@ -17,6 +12,7 @@ import { ResourceListState, ResourceShelfAction, ResourceSourceShelf, + ResourceTabDescription, useResourceRouteLabel, } from "@bb/shared-ui/resource-list"; import { BrowseArchetypeCards } from "@/components/plugin/browse-hero/BrowseArchetypeCards"; @@ -27,11 +23,9 @@ import { getPluginsRoutePath } from "@/lib/route-paths"; import { usePluginCatalogSearch } from "@/hooks/queries/plugin-catalog-queries"; import type { AddPluginInitial } from "./AddPluginDialog"; import { PluginCatalogCard, PluginCatalogGrid } from "./PluginCatalogCard"; -import { - PluginBrowseToolbar, - pluginBrowseSort, - pluginBrowseSortDirection, -} from "./PluginBrowseControls"; +import { PluginCollectionToolbar } from "./PluginBrowseControls"; +import { PluginCreateButton } from "../PluginCreateButton"; +import { PLUGINS_BROWSE_DESCRIPTION } from "../plugins-collection-copy"; import { pluginBrowseShelves, pluginCategoryFilterId, @@ -52,18 +46,18 @@ export function BrowsePluginsTab({ onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; onInstallFromSource: () => void; }) { - const [searchParams, setSearchParams] = useSearchParams(); + const isCompact = useIsCompactViewport(); + const { + searchParams, + query, + requestedSort, + sortDirection, + selectedCategories, + changeSearchParams, + } = usePluginCollectionParams(); const shelfKey = searchParams.get("shelf"); const isCategoryShelf = shelfKey?.startsWith("category:") ?? false; - const query = searchParams.get("query") ?? ""; const creationViewActive = searchParams.get("view") === "create"; - const selectedCategories = useMemo( - () => (isCategoryShelf ? [] : searchParams.getAll("category")), - [isCategoryShelf, searchParams], - ); - const requestedSort = pluginBrowseSort(searchParams.get("sort")); - const sortDirection = - pluginBrowseSortDirection(searchParams.get("direction")) ?? "desc"; const [heroRequest, setHeroRequest] = useState<{ nonce: number; seed?: string; @@ -149,19 +143,23 @@ export function BrowsePluginsTab({ browseParams.delete("shelf"); const browseSearch = browseParams.toString(); - const changeSearchParams = ( - change: (next: URLSearchParams) => void, - replace = true, - ) => { - const next = new URLSearchParams(searchParams); - change(next); - setSearchParams(next, { replace }); - }; const openComposer = (seed?: string) => setHeroRequest({ nonce: nextComposerRequestNonce(), ...(seed === undefined ? {} : { seed }), }); + const createAction = ( + { + if (seed !== undefined) { + openComposer(seed); + } else if (!creationViewActive) { + changeSearchParams((next) => next.set("view", "create"), false); + } + }} + onInstallFromSource={onInstallFromSource} + /> + ); if (requestedCreationView !== creationViewActive) { setRequestedCreationView(creationViewActive); setHeroRequest({ @@ -219,44 +217,18 @@ export function BrowsePluginsTab({ ) : ( <> -
- -
- - - - - - - - - Install from source - - - + {isCompact ? ( + + {PLUGINS_BROWSE_DESCRIPTION} + + ) : ( +
+ + {createAction}
-
+ )} -
+
) : ( -
- + {(searchQuery.isError || activeQuery.isError) && @@ -315,7 +288,10 @@ export function BrowsePluginsTab({ state="empty" message="No plugins match these category filters." /> - ) : sort === null && shelfKey === null ? ( + ) : sort === null && + shelfKey === null && + selectedCategories.length === 0 && + query.trim().length === 0 ? (
{shelves.map((shelf) => ( { "Open Alpha details", ]); const sort = screen.getByRole("button", { - name: "Sort: Most installed, descending", + name: "Sort: Installs, descending", }); fireEvent.pointerDown(sort); - fireEvent.click( - screen.getByRole("menuitemradio", { name: "Most installed" }), - ); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Installs" })); expect(cardOrder()).toEqual([ "Open Beta details", "Open Gamma details", diff --git a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx index f6da55c607c..5d2bb4182cb 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorPage.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorPage.tsx @@ -1,5 +1,6 @@ +import { usePluginCollectionParams } from "./usePluginCollectionParams"; import { useMemo } from "react"; -import { Link, useSearchParams } from "react-router-dom"; +import { Link } from "react-router-dom"; import { Icon } from "@bb/shared-ui/icon"; import { ResourceCollectionViewport, @@ -16,11 +17,7 @@ import { getPluginsRoutePath } from "@/lib/route-paths"; import type { AddPluginInitial } from "./AddPluginDialog"; import { PluginCatalogGrid } from "./PluginCatalogCard"; import { PluginAuthorAvatar } from "./PluginAuthorAvatar"; -import { - PluginBrowseToolbar, - pluginBrowseSort, - pluginBrowseSortDirection, -} from "./PluginBrowseControls"; +import { PluginCollectionToolbar } from "./PluginBrowseControls"; import { pluginCategoryFilterId, pluginCategoryFilterOptions, @@ -70,13 +67,15 @@ export function PluginAuthorPage({ onInstall: (initial: AddPluginInitial) => void; onOpenPlugin: (pluginId: string, trigger: HTMLButtonElement) => void; }) { - const [searchParams, setSearchParams] = useSearchParams(); - const query = searchParams.get("query") ?? ""; + const { + searchParams, + query, + requestedSort, + sortDirection, + selectedCategories, + changeSearchParams, + } = usePluginCollectionParams(); const debouncedQuery = useDebouncedValue(query.trim(), 300); - const selectedCategories = searchParams.getAll("category"); - const requestedSort = pluginBrowseSort(searchParams.get("sort")); - const sortDirection = - pluginBrowseSortDirection(searchParams.get("direction")) ?? "desc"; const catalogQuery = usePluginCatalogSearch("", { enabled: true }); const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: debouncedQuery !== "", @@ -131,12 +130,6 @@ export function PluginAuthorPage({ browseParams.delete("author"); const browseSearch = browseParams.toString(); - const changeSearchParams = (change: (next: URLSearchParams) => void) => { - const next = new URLSearchParams(searchParams); - change(next); - setSearchParams(next, { replace: true }); - }; - return ( {author.url.startsWith("https://github.com/") ? ( - + ) : null} {formatUrlLabel(author.url)} @@ -202,7 +199,7 @@ export function PluginAuthorPage({ ) : (
- ({ compact: false })); +vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ + useIsCompactViewport: () => viewport.compact, +})); + const OPTIONS: PluginBrowseCategoryOption[] = [ { id: "memory-and-context", label: "Memory & Context", count: 4 }, { id: "security", label: "Security", count: 2 }, @@ -22,11 +38,14 @@ function openMenu(selectionLabel: string) { afterEach(() => { cleanup(); + document.getElementById("toolbar-test-layout")?.remove(); + viewport.compact = false; vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("PluginBrowseCategoryFilter", () => { - it("shows searchable counts and checkboxes", () => { + it("shows category counts and checkboxes without search", () => { render( { .querySelector("[data-category-option-checkbox]") ?.getAttribute("data-state"), ).toBe("disabled"); - const search = screen.getByRole("combobox", { - name: "Search plugin categories", - }); - fireEvent.change(search, { target: { value: "work" } }); - expect(screen.getAllByRole("option")).toHaveLength(1); - expect(screen.getByRole("option").textContent).toContain( - "Tasks & Workflows", - ); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.queryByRole("textbox")).toBeNull(); }); it("keeps the menu open for multiple selections", () => { @@ -69,6 +82,11 @@ describe("PluginBrowseCategoryFilter", () => { } render(); openMenu("All categories"); + expect( + screen + .getByRole("button", { name: "Clear filter" }) + .hasAttribute("disabled"), + ).toBe(true); fireEvent.click(screen.getByRole("option", { name: /Security/u })); fireEvent.click(screen.getByRole("option", { name: /Tasks & Workflows/u })); @@ -80,9 +98,14 @@ describe("PluginBrowseCategoryFilter", () => { expect( screen.getByRole("button", { name: "Filter plugins by category: Security, Tasks & Workflows", - }).textContent, - ).toContain("2 categories"); + }), + ).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + expect( + screen + .getByRole("button", { name: "Clear filter" }) + .hasAttribute("disabled"), + ).toBe(true); expect( screen.getByRole("button", { name: "Filter plugins by category: All categories", @@ -90,7 +113,7 @@ describe("PluginBrowseCategoryFilter", () => { ).toBeTruthy(); }); - it("moves focus through options with the keyboard", () => { + it("moves focus through options with the keyboard", async () => { const onChange = vi.fn(); render( { />, ); openMenu("Tasks & Workflows"); - const search = screen.getByRole("combobox", { - name: "Search plugin categories", + const firstOption = screen.getByRole("option", { + name: /Memory & Context/u, }); - fireEvent.keyDown(search, { key: "ArrowDown" }); - expect(document.activeElement?.textContent).toContain("Memory & Context"); + await waitFor(() => expect(document.activeElement).toBe(firstOption)); + fireEvent.keyDown(firstOption, { key: "ArrowDown" }); + expect(document.activeElement).toBe( + screen.getByRole("option", { name: /Security/u }), + ); fireEvent.keyDown(document.activeElement as HTMLElement, { key: "End" }); expect(document.activeElement?.textContent).toContain("Tasks & Workflows"); fireEvent.click(document.activeElement as HTMLElement); expect(onChange).toHaveBeenCalledWith([]); }); - it("keeps keyboard focus inside each filter instance", () => { + it("keeps keyboard focus inside each filter instance", async () => { render( <> { name: "Filter plugins by category: All categories", }); fireEvent.click(triggers[0] as HTMLButtonElement); - const firstSearch = screen.getByRole("combobox", { - name: "Search plugin categories", - }); const firstList = screen.getByRole("listbox", { name: "Plugin categories", }); - expect(firstSearch.getAttribute("aria-controls")).toBe(firstList.id); + await waitFor(() => + expect(firstList.contains(document.activeElement)).toBe(true), + ); fireEvent.click(triggers[0] as HTMLButtonElement); + await waitFor(() => expect(document.activeElement).toBe(triggers[0])); fireEvent.click(triggers[1] as HTMLButtonElement); - const secondSearch = screen.getByRole("combobox", { - name: "Search plugin categories", - }); const secondList = screen.getByRole("listbox", { name: "Plugin categories", }); - expect(secondSearch.getAttribute("aria-controls")).toBe(secondList.id); - expect(firstList.id).not.toBe(secondList.id); - - fireEvent.keyDown(secondSearch, { key: "ArrowDown" }); + const firstOption = screen.getByRole("option", { + name: /Memory & Context/u, + }); + await waitFor(() => expect(document.activeElement).toBe(firstOption)); + fireEvent.keyDown(firstOption, { key: "ArrowDown" }); expect(secondList.contains(document.activeElement)).toBe(true); expect(firstList.contains(document.activeElement)).toBe(false); }); }); + +function ToolbarHarness({ + installed = false, + categoryShelf = false, + createAction = false, + installsKnown = false, +}: { + installed?: boolean; + categoryShelf?: boolean; + createAction?: boolean; + installsKnown?: boolean; +}) { + const [params, setParams] = useState( + new URLSearchParams( + "query=Memory&category=security&source=user&sort=name&direction=desc", + ), + ); + const sort = params.get("sort"); + const changeSearchParams = (change: (next: URLSearchParams) => void) => { + setParams((previous) => { + const next = new URLSearchParams(previous); + change(next); + return next; + }); + }; + return ( + <> + Create : undefined + } + query={params.get("query") ?? ""} + selectedCategories={params.getAll("category")} + categoryOptions={OPTIONS} + showCategoryFilter={!categoryShelf} + sort={ + sort === "name" || + sort === "recently-added" || + sort === "most-installed" + ? sort + : null + } + sortDirection={params.get("direction") === "desc" ? "desc" : "asc"} + installsKnown={installsKnown} + changeSearchParams={changeSearchParams} + sourceFilter={ + installed + ? { + options: [ + { id: "user", label: "Local" }, + { id: "official", label: "BB Official" }, + ], + selectedValues: params.getAll("source"), + onChange: (values) => + changeSearchParams((next) => { + next.delete("source"); + values.forEach((value) => next.append("source", value)); + }), + } + : undefined + } + /> + {params.toString()} + + ); +} + +function mockToolbarWidth(initial: number, publishedWidth = 96) { + let width = initial; + const callbacks = new Set<() => void>(); + const original = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.hasAttribute("data-resource-toolbar")) + return new DOMRect(0, 0, width, 32); + if (this.hasAttribute("data-resource-individual-controls")) + return new DOMRect( + 0, + 0, + [...this.querySelectorAll("button")].reduce( + (total, button) => + total + + (button.textContent === "Published" ? publishedWidth : 96), + 0, + ), + 32, + ); + if (this.hasAttribute("data-resource-combined-controls")) + return new DOMRect(0, 0, 32, 32); + if (this.hasAttribute("data-resource-toolbar-action")) + return new DOMRect(0, 0, 120, 32); + return original.call(this); + }, + ); + const style = document.createElement("style"); + style.id = "toolbar-test-layout"; + style.textContent = ` + [data-resource-toolbar] { column-gap: 8px; } + [data-resource-toolbar] > form { flex-basis: 160px; } + `; + document.head.append(style); + vi.stubGlobal( + "ResizeObserver", + class { + callback: () => void; + constructor(callback: () => void) { + this.callback = callback; + } + observe(target: Element) { + if (target.hasAttribute("data-resource-toolbar")) + callbacks.add(this.callback); + } + unobserve() {} + disconnect() { + callbacks.delete(this.callback); + } + }, + ); + return (next: number) => + act(() => { + width = next; + callbacks.forEach((callback) => callback()); + }); +} + +describe("PluginCollectionToolbar", () => { + it.each([ + { control: /^Filter plugins by category:/u, tooltip: "Category: Security" }, + { control: /^Source:/u, tooltip: "Source: Local" }, + { control: /^Sort:/u, tooltip: "Sort: Name · Z–A" }, + ])( + "describes applied selections in the $tooltip tooltip", + async ({ control, tooltip }) => { + mockToolbarWidth(800); + render(); + const trigger = screen.getByRole("button", { name: control }); + act(() => trigger.focus()); + expect((await screen.findByRole("tooltip")).textContent).toBe(tooltip); + }, + ); + + it("describes all applied selections in the combined trigger tooltip", async () => { + mockToolbarWidth(320); + render(); + const trigger = screen.getByRole("button", { name: "Filter & sort" }); + expect(trigger.textContent).toBe(""); + expect( + trigger.querySelector('[data-icon="FilterHorizontal"]'), + ).not.toBeNull(); + act(() => trigger.focus()); + expect((await screen.findByRole("tooltip")).textContent).toBe( + "Category: Security; Source: Local; Sort: Name · Z–A", + ); + }); + + it.each([ + { + control: /^Source:/u, + heading: "Source", + icon: "Download", + role: "menuitemcheckbox" as const, + }, + { + control: /^Sort:/u, + heading: "Sort", + icon: "SortingZA01", + role: "menuitemradio" as const, + }, + ])( + "opens $heading without a redundant heading", + ({ control, heading, icon, role }) => { + mockToolbarWidth(800); + render(); + const trigger = screen.getByRole("button", { name: control }); + expect(trigger.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); + expect(trigger.querySelector('[data-icon="Layers"]')).toBeNull(); + expect(trigger.querySelector(`[data-icon="${icon}"]`)).not.toBeNull(); + fireEvent.keyDown(trigger, { key: "Enter" }); + const menu = screen.getByRole("menu"); + expect(screen.getAllByRole(role).length).toBeGreaterThan(0); + expect(within(menu).queryByText(heading)).toBeNull(); + }, + ); + + it("keeps an open sort menu stable when its selected label grows, then combines after dismissal", async () => { + const resize = mockToolbarWidth(370, 128); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: /^Sort:/u }), { + key: "Enter", + }); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Published" })); + resize(370); + expect( + screen.getByRole("menuitemradio", { name: "Published" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); + fireEvent.keyDown(document.activeElement ?? document.body, { + key: "Escape", + }); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Filter & sort" }), + ).toBeTruthy(), + ); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "sort=recently-added&direction=desc", + ); + }); + + it.each([ + { label: "Name", value: "name", direction: "asc" }, + { label: "Published", value: "recently-added", direction: "desc" }, + { label: "Installs", value: "most-installed", direction: "desc" }, + ])( + "updates the selected $label label and direction without clearing filters", + ({ label, value, direction }) => { + mockToolbarWidth(800); + render(); + const trigger = screen.getByRole("button", { name: /^Sort:/u }); + fireEvent.keyDown(trigger, { + key: "Enter", + }); + fireEvent.click(screen.getByRole("menuitemradio", { name: label })); + expect(screen.getByLabelText("Parameters").textContent).toContain( + `sort=${value}&direction=${direction}`, + ); + expect(trigger.textContent).toBe(label); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "query=Memory&category=security&source=user", + ); + fireEvent.click(screen.getByRole("menuitemradio", { name: label })); + expect(screen.getByLabelText("Parameters").textContent).toContain( + `direction=${direction === "asc" ? "desc" : "asc"}`, + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); + expect(trigger.textContent).toBe("Sort"); + }, + ); + + it("shows actual selections and sort direction in the combined menu", () => { + mockToolbarWidth(320); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Filter & sort" }), { + key: "Enter", + }); + expect( + screen.getByRole("menuitem", { name: /Category.*Security/u }), + ).toBeTruthy(); + expect( + screen.getByRole("menuitem", { name: /Source.*Local/u }), + ).toBeTruthy(); + expect( + screen.getByRole("menuitem", { name: /Sort.*Name · Z–A/u }), + ).toBeTruthy(); + }); + + it("opens compact search with the query selected and restores controls on submit or blur", () => { + mockToolbarWidth(320); + render(); + const trigger = screen.getByRole("button", { name: "Search plugins" }); + expect( + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + fireEvent.click(trigger); + const search = screen.getByRole("textbox", { + name: "Search plugins", + }); + expect(document.activeElement).toBe(search); + expect(search.selectionStart).toBe(0); + expect(search.selectionEnd).toBe("Memory".length); + expect(search.placeholder).toBe("Search plugins..."); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); + fireEvent.change(search, { target: { value: "Notes" } }); + fireEvent.keyDown(search, { key: "Enter", isComposing: true }); + expect(document.activeElement).toBe(search); + fireEvent.keyDown(search, { key: "Enter" }); + expect( + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Search plugins" }), + ); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Notes&category=security&source=user&sort=name&direction=desc", + ); + fireEvent.click(screen.getByRole("button", { name: "Search plugins" })); + act(() => screen.getByRole("textbox", { name: "Search plugins" }).blur()); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); + }); + + it("clears compact search, dismisses the input, and retains the other selections", () => { + mockToolbarWidth(320); + render(); + fireEvent.click(screen.getByRole("button", { name: "Search plugins" })); + const search = screen.getByRole("textbox", { name: "Search plugins" }); + fireEvent.change(search, { target: { value: "Notes" } }); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "query=Notes", + ); + expect(screen.queryByRole("button", { name: "Search" })).toBeNull(); + const clear = screen.getByRole("button", { name: "Clear search" }); + act(() => clear.focus()); + expect(document.activeElement).toBe(clear); + expect(screen.queryByRole("button", { name: "Create" })).toBeNull(); + fireEvent.click(clear); + expect( + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Search plugins" }), + ); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "category=security&source=user&sort=name&direction=desc", + ); + fireEvent.click(screen.getByRole("button", { name: "Search plugins" })); + const reopened = screen.getByRole("textbox", { name: "Search plugins" }); + expect(reopened.getAttribute("value")).toBe(""); + fireEvent.keyDown(reopened, { key: "Escape" }); + expect( + screen.queryByRole("textbox", { name: "Search plugins" }), + ).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Search plugins" }), + ); + }); + + it("clears and blurs wide search without removing filters", () => { + mockToolbarWidth(800); + render(); + const search = screen.getByRole("textbox", { name: "Search plugins" }); + act(() => search.focus()); + fireEvent.change(search, { target: { value: "Notes" } }); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(document.activeElement).not.toBe(search); + expect(search.getAttribute("value")).toBe(""); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "category=security&source=user&sort=name&direction=desc", + ); + expect(screen.queryByRole("button", { name: "Clear search" })).toBeNull(); + expect(screen.getByRole("button", { name: /^Sort:/u })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + }); + + it("keeps mobile search inline when a lone Sort control leaves its minimum width", () => { + viewport.compact = true; + const resize = mockToolbarWidth(400); + render(); + expect( + screen.getByRole("textbox", { name: "Search plugins" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Search plugins" })).toBeNull(); + resize(260); + expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); + resize(264); + expect( + screen.getByRole("textbox", { name: "Search plugins" }) + .value, + ).toBe("Memory"); + expect(screen.getByRole("button", { name: /^Sort:/u })).toBeTruthy(); + }); + + it("pairs combined controls with square search until separate controls fit", () => { + viewport.compact = true; + const resize = mockToolbarWidth(354); + render(); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: "Search plugins" })).toBeNull(); + expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); + resize(320); + expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); + resize(328); + expect(screen.getByRole("button", { name: "Search plugins" })).toBeTruthy(); + resize(800); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); + expect( + screen.getByRole("textbox", { name: "Search plugins" }) + .value, + ).toBe("Memory"); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + }); + + it("dismisses inline mobile search on Enter while preserving the query", () => { + viewport.compact = true; + mockToolbarWidth(400); + render(); + const search = screen.getByRole("textbox", { + name: "Search plugins", + }); + act(() => search.focus()); + fireEvent.change(search, { target: { value: "Notes" } }); + fireEvent.keyDown(search, { key: "Enter" }); + expect(document.activeElement).not.toBe(search); + expect(search.value).toBe("Notes"); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "query=Notes", + ); + }); + + it.each([ + { + installed: true, + categoryShelf: false, + labels: ["Category", "Source", "Sort"], + }, + { installed: false, categoryShelf: false, labels: ["Category", "Sort"] }, + ])( + "preserves the allowed combined controls for %j", + ({ labels, ...props }) => { + mockToolbarWidth(320); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Filter & sort" }), { + key: "Enter", + }); + expect( + screen + .getAllByRole("menuitem") + .map( + (item) => item.querySelector("[data-control-page]")?.textContent, + ), + ).toEqual(labels); + expect(screen.queryByRole("button", { name: /^Sort:/u })).toBeNull(); + }, + ); + + it("keeps a lone Sort control directly accessible at narrow widths", () => { + mockToolbarWidth(240); + render(); + expect(screen.getByRole("button", { name: /^Sort:/u }).textContent).toBe( + "Name", + ); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); + }); + + it("uses the space required by each surface instead of a common viewport cutoff", () => { + const resize = mockToolbarWidth(400); + const { rerender } = render(); + expect(screen.getByRole("button", { name: /^Sort:/u }).textContent).toBe( + "Name", + ); + expect( + screen.getByRole("button", { name: /^Filter plugins by category:/u }) + .textContent, + ).toBe("Category1"); + rerender(); + resize(400); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); + }); + + it("reserves space for the create action before expanding controls", () => { + mockToolbarWidth(500); + render(); + expect(screen.getByRole("button", { name: "Filter & sort" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + }); + + it("shares sort, category selection, and source clearing in the combined menu without resetting other values", async () => { + mockToolbarWidth(320); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Filter & sort" }), { + key: "Enter", + }); + fireEvent.click(screen.getByRole("menuitem", { name: /^Sort/u })); + const sort = screen.getByRole("menuitemradio", { name: "Name" }); + await waitFor(() => expect(document.activeElement).toBe(sort)); + fireEvent.click(sort); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "direction=asc", + ); + expect( + screen + .getByRole("menuitemradio", { name: "Installs" }) + .getAttribute("aria-disabled"), + ).toBe("true"); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Memory&category=security&source=user", + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Filter & sort" })); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: /^Sort/u }), + ), + ); + fireEvent.click(screen.getByRole("menuitem", { name: /^Category/u })); + expect(screen.queryByRole("combobox")).toBeNull(); + const firstOption = screen.getByRole("option", { + name: /Memory & Context/u, + }); + await waitFor(() => expect(document.activeElement).toBe(firstOption)); + fireEvent.keyDown(firstOption, { key: "ArrowDown" }); + expect(document.activeElement).toBe( + screen.getByRole("option", { name: /Security/u }), + ); + fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Memory&source=user", + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Filter & sort" })); + fireEvent.click(screen.getByRole("menuitem", { name: /^Source/u })); + fireEvent.click(screen.getByRole("menuitem", { name: "Clear filter" })); + expect(screen.getByLabelText("Parameters").textContent).toBe( + "query=Memory", + ); + fireEvent.keyDown(document.activeElement ?? document.body, { + key: "Escape", + }); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Filter & sort" }), + ), + ); + }); + + it("restores focus to the category entry in the compact drawer", async () => { + viewport.compact = true; + mockToolbarWidth(320); + render(); + fireEvent.click(screen.getByRole("button", { name: "Filter & sort" })); + fireEvent.click( + await screen.findByRole("menuitem", { name: /^Category/u }), + ); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("option", { name: /Memory & Context/u }), + ), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Filter & sort" })); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: /^Category/u }), + ), + ); + }); + + it("moves focused controls into the combined menu and back without losing search or selection", () => { + const resize = mockToolbarWidth(600); + render(); + fireEvent.change(screen.getByRole("textbox", { name: "Search plugins" }), { + target: { value: "Notes" }, + }); + screen.getByRole("button", { name: /^Source:/u }).focus(); + resize(320); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Filter & sort" }), + ); + resize(600); + expect(screen.queryByRole("button", { name: "Filter & sort" })).toBeNull(); + expect(screen.getByRole("button", { name: /^Source: 1/u })).toBeTruthy(); + expect( + screen + .getByRole("textbox", { name: "Search plugins" }) + .getAttribute("value"), + ).toBe("Notes"); + expect(screen.getByLabelText("Parameters").textContent).toContain( + "category=security&source=user&sort=name&direction=desc", + ); + }); +}); diff --git a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx index 2eafc2fd09a..c7da9e57e1b 100644 --- a/apps/app/src/components/plugin/management/PluginBrowseControls.tsx +++ b/apps/app/src/components/plugin/management/PluginBrowseControls.tsx @@ -1,10 +1,29 @@ -import { useCallback, useEffect, useId, useRef, useState } from "react"; -import { Button } from "@bb/shared-ui/button"; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { Icon, type IconName } from "@bb/shared-ui/icon"; -import { Input } from "@bb/shared-ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@bb/shared-ui/popover"; import { cn } from "@bb/shared-ui/lib/utils"; -import { ResourceSortMenu, ResourceToolbar } from "@bb/shared-ui/resource-list"; +import { + ResourceControlButton, + ResourceMultiSelectMenu, + ResourceMultiSelectMenuItems, + ResourceSortMenu, + ResourceSortMenuItems, + ResourceToolbar, + type ResourceOption, +} from "@bb/shared-ui/resource-list"; import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; import type { PluginBrowseSort, @@ -13,42 +32,58 @@ import type { } from "./plugin-browse-discovery"; const PLUGIN_BROWSE_SORTS = [ + "name", "recently-added", "most-installed", ] as const satisfies readonly PluginBrowseSort[]; const PLUGIN_BROWSE_SORT_LABELS: Record = { - "recently-added": "Recently added", - "most-installed": "Most installed", + name: "Name", + "recently-added": "Published", + "most-installed": "Installs", }; -const PLUGIN_BROWSE_SORT_ICONS: Record = { - "recently-added": "Clock", - "most-installed": "Download", +const PLUGIN_BROWSE_SORT_ICONS: Record< + PluginBrowseSort, + Record +> = { + name: { asc: "SortingAZ02", desc: "SortingZA01" }, + "recently-added": { asc: "ClockArrowUp", desc: "ClockArrowDown" }, + "most-installed": { asc: "SortingOneNine", desc: "SortingNineOne" }, }; -export function pluginBrowseSort( - value: string | null, -): PluginBrowseSort | null { - return PLUGIN_BROWSE_SORTS.find((sort) => sort === value) ?? null; -} - -export function pluginBrowseSortDirection( - value: string | null, -): PluginBrowseSortDirection | null { - return value === "asc" || value === "desc" ? value : null; -} +const PLUGIN_BROWSE_SORT_DIRECTIONS: Record< + PluginBrowseSort, + Record +> = { + name: { asc: "A–Z", desc: "Z–A" }, + "recently-added": { asc: "Oldest first", desc: "Newest first" }, + "most-installed": { asc: "Fewest first", desc: "Most first" }, +}; -export function pluginBrowseSortOptions(hasInstallCounts: boolean) { - return PLUGIN_BROWSE_SORTS.map((sort) => ({ - id: sort, - label: PLUGIN_BROWSE_SORT_LABELS[sort], - leading: , - disabled: sort === "most-installed" && !hasInstallCounts, - })); +export function pluginBrowseSortOptions( + hasInstallCounts: boolean, + selected: PluginBrowseSort | null = null, + direction: PluginBrowseSortDirection = "asc", +) { + return PLUGIN_BROWSE_SORTS.map((sort) => { + const optionDirection = + sort === selected ? direction : sort === "name" ? "asc" : "desc"; + return { + id: sort, + label: PLUGIN_BROWSE_SORT_LABELS[sort], + leading: ( + + ), + disabled: sort === "most-installed" && !hasInstallCounts, + }; + }); } -export function PluginBrowseToolbar({ +export function PluginCollectionToolbar({ query, selectedCategories, categoryOptions, @@ -57,7 +92,17 @@ export function PluginBrowseToolbar({ sortDirection, installsKnown, changeSearchParams, + searchPlaceholder = "Search plugins", + action, + sourceFilter, }: { + searchPlaceholder?: string; + action?: ReactNode; + sourceFilter?: { + options: readonly ResourceOption[]; + selectedValues: readonly string[]; + onChange: (values: string[]) => void; + }; query: string; selectedCategories: readonly string[]; categoryOptions: readonly PluginBrowseCategoryOption[]; @@ -67,11 +112,110 @@ export function PluginBrowseToolbar({ installsKnown: boolean; changeSearchParams: (change: (next: URLSearchParams) => void) => void; }) { + const sortProps = { + value: sort, + direction: sortDirection, + compact: true, + clearInFooter: true, + showHeading: false, + placeholderLabel: "Default", + options: pluginBrowseSortOptions(installsKnown, sort, sortDirection), + onChange: (value: string) => + changeSearchParams((next) => { + if (value === sort) { + next.set("direction", sortDirection === "asc" ? "desc" : "asc"); + } else { + next.set("sort", value); + next.set("direction", value === "name" ? "asc" : "desc"); + } + }), + onClear: () => + changeSearchParams((next) => { + next.delete("sort"); + next.delete("direction"); + }), + }; + const categoryProps = { + value: selectedCategories, + options: categoryOptions, + onChange: (values: string[]) => + changeSearchParams((next) => { + next.delete("category"); + for (const value of values) next.append("category", value); + }), + }; + const sourceProps = sourceFilter + ? { + ...sourceFilter, + label: "Source", + icon: "Download" as const, + compact: true, + clearInFooter: true, + showHeading: false, + } + : null; + const sortIcon = + sort === null + ? "ArrowUpDown" + : PLUGIN_BROWSE_SORT_ICONS[sort][sortDirection]; + const sortSummary = + sort === null + ? "Default" + : `${PLUGIN_BROWSE_SORT_LABELS[sort]} · ${PLUGIN_BROWSE_SORT_DIRECTIONS[sort][sortDirection]}`; + const controls = [ + ...(showCategoryFilter + ? [ + { + id: "category", + label: "Category", + icon: "SlidersHorizontal", + active: selectedCategories.length > 0, + summary: + categoryOptions + .filter((option) => selectedCategories.includes(option.id)) + .map((option) => option.label) + .join(", ") || "All", + content: , + } satisfies PluginControlPage, + ] + : []), + ...(sourceProps + ? [ + { + id: "source", + label: "Source", + icon: sourceProps.icon, + active: sourceProps.selectedValues.length > 0, + summary: + sourceProps.options + .filter((option) => + sourceProps.selectedValues.includes(option.id), + ) + .map((option) => option.label) + .join(", ") || "All", + content: , + } satisfies PluginControlPage, + ] + : []), + { + id: "sort", + label: "Sort", + icon: sortIcon, + active: sort !== null, + summary: sortSummary, + content: , + }, + ] satisfies PluginControlPage[]; + return (
changeSearchParams((next) => { if (value === "") next.delete("query"); @@ -81,54 +225,146 @@ export function PluginBrowseToolbar({ controls={ <> {showCategoryFilter ? ( - - changeSearchParams((next) => { - next.delete("category"); - for (const value of values) { - next.append("category", value); - } - }) - } - /> + + ) : null} + {sourceProps ? ( + ) : null} - changeSearchParams((next) => { - if (value === sort) { - next.set( - "direction", - sortDirection === "asc" ? "desc" : "asc", - ); - } else { - next.set("sort", value); - next.set("direction", "desc"); - } - }) - } - onClear={() => - changeSearchParams((next) => { - next.delete("sort"); - next.delete("direction"); - }) - } + {...sortProps} + showLabel + triggerIcon={sortIcon} + tooltip={`Sort: ${sortSummary}`} /> } + combinedControls={ + controls.length > 1 ? ( + + ) : undefined + } />
); } -const ENGAGED_CONTROL_CLASS = - "bg-state-active text-foreground hover:bg-state-active"; +type PluginControlPage = { + id: string; + label: string; + icon?: IconName; + active: boolean; + summary: string; + content: ReactNode; +}; + +function PluginControlsMenu({ + pages, +}: { + pages: readonly PluginControlPage[]; +}) { + const [open, setOpen] = useState(false); + const [pageId, setPageId] = useState(null); + const contentRef = useRef(null); + const returnPageRef = useRef(null); + const page = pages.find((candidate) => candidate.id === pageId); + const activeLabels = pages + .filter((candidate) => candidate.active) + .map((candidate) => `${candidate.label}: ${candidate.summary}`); + + useEffect(() => { + if (!open) return; + const frame = requestAnimationFrame(() => { + const content = contentRef.current; + if (pageId) { + content + ?.querySelector( + 'input, [role="menuitemradio"], [role="menuitemcheckbox"]', + ) + ?.focus(); + } else if (returnPageRef.current) { + content + ?.querySelector( + `[data-control-page="${returnPageRef.current}"]`, + ) + ?.closest('[role="menuitem"]') + ?.focus(); + } + }); + return () => cancelAnimationFrame(frame); + }, [open, pageId]); + + return ( + { + setOpen(next); + if (!next) { + setPageId(null); + returnPageRef.current = null; + } + }} + > + + 0} + open={open} + /> + + + {page ? ( + <> + { + event.preventDefault(); + returnPageRef.current = page.id; + setPageId(null); + }} + > + + Filter & sort + + + {page.content} + + ) : ( + pages.map((control) => ( + { + event.preventDefault(); + setPageId(control.id); + }} + > + {control.icon ? ( + + ) : null} + + {control.label} + + + {control.summary} + + + + )) + )} + + + ); +} const SCROLLBAR_IDLE_DELAY_MS = 600; @@ -150,43 +386,55 @@ function CategoryOptionCheckbox({ enabled }: { enabled: boolean }) { ); } -export function PluginBrowseCategoryFilter({ - options, - value, - onChange, -}: { +type PluginCategoryFilterProps = { options: readonly PluginBrowseCategoryOption[]; value: readonly string[]; onChange: (value: string[]) => void; -}) { +}; + +export function PluginBrowseCategoryFilter(props: PluginCategoryFilterProps) { + const { options, value } = props; const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const [showKeyboardFocus, setShowKeyboardFocus] = useState(false); - const [scrollbarScrolling, setScrollbarScrolling] = useState(false); - const listboxId = useId(); - const inputRef = useRef(null); - const keyboardFocusRef = useRef(false); - const selected = new Set(value); const selectedOptions = value.flatMap((selectedId) => { const option = options.find((candidate) => candidate.id === selectedId); return option === undefined ? [] : [option]; }); - const selectionLabel = - selectedOptions.length === 0 - ? "All categories" - : selectedOptions.length === 1 - ? (selectedOptions[0]?.label ?? "Category") - : `${selectedOptions.length} categories`; const accessibleSelectionLabel = selectedOptions.length === 0 ? "All categories" : selectedOptions.map((option) => option.label).join(", "); - const normalizedSearch = search.trim().toLocaleLowerCase(); - const filteredOptions = options.filter((option) => - `${option.label} ${option.id}` - .toLocaleLowerCase() - .includes(normalizedSearch), + return ( + + + 0} + open={open} + /> + + + {open ? : null} + + ); +} + +function PluginCategoryOptions({ + options, + value, + onChange, +}: PluginCategoryFilterProps) { + const [scrollbarScrolling, setScrollbarScrolling] = useState(false); + const selected = new Set(value); const [listElement, setListElement] = useState(null); const { scrollRef: listRef, @@ -207,12 +455,11 @@ export function PluginBrowseCategoryFilter({ const scrollbarIdleRef = useRef(null); useEffect(() => { - if (!open) return; const animationFrame = requestAnimationFrame(() => - inputRef.current?.focus(), + categoryOptionElements(listRef.current)[0]?.focus(), ); return () => cancelAnimationFrame(animationFrame); - }, [open]); + }, [listRef]); useEffect( () => () => { @@ -248,168 +495,75 @@ export function PluginBrowseCategoryFilter({ }; return ( - { - setOpen(nextOpen); - if (!nextOpen) setSearch(""); - }} - > - - - - -
- - setSearch(event.target.value)} - placeholder="Search categories" - aria-label="Search plugin categories" - role="combobox" - aria-controls={listboxId} - aria-expanded={open} - aria-autocomplete="list" - className={cn( - "h-7 border-transparent bg-surface-recessed pl-7 pr-2 text-xs focus-visible:ring-0", - showKeyboardFocus && "ring-1 ring-ring", - )} - onFocus={() => setShowKeyboardFocus(keyboardFocusRef.current)} - onBlur={() => setShowKeyboardFocus(false)} - onPointerDown={() => { - keyboardFocusRef.current = false; - setShowKeyboardFocus(false); - }} - onKeyDown={(event) => { - keyboardFocusRef.current = true; - setShowKeyboardFocus(true); - if (event.key === "ArrowDown") { - event.preventDefault(); - categoryOptionElements(listElement)[0]?.focus(); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - categoryOptionElements(listElement).at(-1)?.focus(); - } else if (event.key !== "Escape" && event.key !== "Tab") { - event.stopPropagation(); - } - }} - /> -
-
-
-
- {filteredOptions.length === 0 ? ( -

- {options.length === 0 - ? "No categories are available." - : "No categories match your search."} -

- ) : ( - filteredOptions.map((option) => { - return ( - - ); - }) - )} -
-
- {belowOverflow ? ( -
- ) : null} + + + {option.label} + + + + ); + }) + )} +
- {value.length > 0 ? ( -
- -
+ {belowOverflow ? ( +
) : null} - - +
+
+ +
+ ); } @@ -428,6 +582,7 @@ function focusCategoryOption( else if (event.key === "End") nextIndex = options.length - 1; if (nextIndex === null) return; event.preventDefault(); + event.stopPropagation(); options[nextIndex]?.focus(); } diff --git a/apps/app/src/components/plugin/management/PluginCard.tsx b/apps/app/src/components/plugin/management/PluginCard.tsx index 805f5a3b6f4..43a4e6db5c3 100644 --- a/apps/app/src/components/plugin/management/PluginCard.tsx +++ b/apps/app/src/components/plugin/management/PluginCard.tsx @@ -11,9 +11,7 @@ import { PluginCategoryLabel } from "./plugin-ui"; export function PluginCardGrid({ children }: { children: ReactNode }) { return ( - + {children} ); @@ -37,8 +35,11 @@ export function PluginCard({ badge, ...props }: PluginCardProps) { return ( {props.description} + } title={ {props.title} } diff --git a/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx b/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx index 91d56a19410..35333661162 100644 --- a/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx +++ b/apps/app/src/components/plugin/management/PluginMarketplaceListing.tsx @@ -79,10 +79,10 @@ function PluginMarketplaceDetails({ ); } -function PluginMarketplaceSource({ +export function PluginMarketplaceSource({ entry, }: { - entry: PluginCatalogSearchEntry; + entry: Pick; }) { if (entry.repositoryUrl === null) return null; return ( @@ -93,6 +93,13 @@ function PluginMarketplaceSource({ rel="noreferrer" className="inline-flex max-w-full items-center gap-1.5 rounded-sm text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > + {entry.repositoryUrl.startsWith("https://github.com/") ? ( + + ) : null} {formatUrlLabel(entry.repositoryUrl)} Opens in a new tab diff --git a/apps/app/src/components/plugin/management/plugin-browse-discovery.test.ts b/apps/app/src/components/plugin/management/plugin-browse-discovery.test.ts index 27e8f9cfd01..b44c6a15e91 100644 --- a/apps/app/src/components/plugin/management/plugin-browse-discovery.test.ts +++ b/apps/app/src/components/plugin/management/plugin-browse-discovery.test.ts @@ -5,6 +5,7 @@ import type { } from "@/hooks/queries/plugin-catalog-queries"; import { pluginBrowseShelves, + pluginCategoryFilterOptions, sortPluginEntries, } from "./plugin-browse-discovery"; @@ -148,6 +149,31 @@ describe("plugin browse shelves", () => { }); }); +describe("plugin category filters", () => { + it("omits missing categories even when previously selected, preserving Local and custom categories", () => { + expect( + pluginCategoryFilterOptions( + [ + entry("categorized"), + entry("no-category", { categoryId: undefined, category: undefined }), + entry("no-id", { categoryId: undefined }), + entry("no-label", { category: undefined }), + entry("local", { categoryId: "local", category: "Local" }), + entry("custom", { + categoryId: "observability", + category: "Observability", + }), + ], + ["uncategorized"], + ), + ).toEqual([ + { id: "thread-content", label: "Thread Content", count: 1 }, + { id: "local", label: "Local", count: 1 }, + { id: "observability", label: "Observability", count: 1 }, + ]); + }); +}); + describe("plugin browse sorting", () => { it("puts entries without a published date last in both directions", () => { const entries = [ diff --git a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts index e22f1a0a828..8f55f10f03e 100644 --- a/apps/app/src/components/plugin/management/plugin-browse-discovery.ts +++ b/apps/app/src/components/plugin/management/plugin-browse-discovery.ts @@ -7,7 +7,7 @@ import type { export const UNCATEGORIZED_PLUGIN_CATEGORY_ID = "uncategorized"; -export type PluginBrowseSort = "recently-added" | "most-installed"; +export type PluginBrowseSort = "name" | "recently-added" | "most-installed"; export type PluginBrowseSortDirection = "asc" | "desc"; export interface PluginBrowseShelf { @@ -26,7 +26,7 @@ export interface PluginBrowseCategoryOption { } export function pluginCategoryFilterOptions( - entries: readonly PluginCatalogSearchEntry[], + entries: readonly Pick[], selected: readonly string[], ): PluginBrowseCategoryOption[] { const labels = new Map(); @@ -34,32 +34,20 @@ export function pluginCategoryFilterOptions( const unknownIds: string[] = []; for (const entry of entries) { const id = pluginCategoryFilterId(entry); + if (id === UNCATEGORIZED_PLUGIN_CATEGORY_ID) continue; if (!labels.has(id)) { - labels.set( - id, - id === UNCATEGORIZED_PLUGIN_CATEGORY_ID - ? "Uncategorized" - : (entry.category ?? id), - ); - if ( - id !== UNCATEGORIZED_PLUGIN_CATEGORY_ID && - pluginCatalogCategory(id) === undefined - ) { + labels.set(id, entry.category ?? id); + if (pluginCatalogCategory(id) === undefined) { unknownIds.push(id); } } counts.set(id, (counts.get(id) ?? 0) + 1); } for (const id of selected) { - if (labels.has(id)) continue; + if (id === UNCATEGORIZED_PLUGIN_CATEGORY_ID || labels.has(id)) continue; const category = pluginCatalogCategory(id); - labels.set( - id, - id === UNCATEGORIZED_PLUGIN_CATEGORY_ID - ? "Uncategorized" - : (category?.displayName ?? id), - ); - if (id !== UNCATEGORIZED_PLUGIN_CATEGORY_ID && category === undefined) { + labels.set(id, category?.displayName ?? id); + if (category === undefined) { unknownIds.push(id); } } @@ -68,9 +56,6 @@ export function pluginCategoryFilterOptions( labels.has(id), ), ...unknownIds, - ...(labels.has(UNCATEGORIZED_PLUGIN_CATEGORY_ID) - ? [UNCATEGORIZED_PLUGIN_CATEGORY_ID] - : []), ]; return orderedIds.map((id) => ({ id, @@ -79,7 +64,9 @@ export function pluginCategoryFilterOptions( })); } -function isCategorized(entry: PluginCatalogSearchEntry): boolean { +function isCategorized( + entry: Pick, +): boolean { return entry.categoryId !== undefined && entry.category !== undefined; } @@ -175,7 +162,7 @@ export function pluginBrowseShelves({ } export function pluginCategoryFilterId( - entry: PluginCatalogSearchEntry, + entry: Pick, ): string { return isCategorized(entry) ? (entry.categoryId ?? UNCATEGORIZED_PLUGIN_CATEGORY_ID) @@ -193,28 +180,36 @@ function compareOptionalNumbers( return direction === "asc" ? result : -result; } -export function sortPluginEntries( - entries: readonly PluginCatalogSearchEntry[], +export function sortPluginEntries< + Entry extends Pick< + PluginCatalogSearchEntry, + "displayName" | "entryId" | "publishedAt" | "installs" + >, +>( + entries: readonly Entry[], sort: PluginBrowseSort, direction: PluginBrowseSortDirection = "desc", -): PluginCatalogSearchEntry[] { +): Entry[] { return [...entries].sort((left, right) => { const sortResult = - sort === "recently-added" - ? compareOptionalNumbers( - left.publishedAt === undefined - ? undefined - : Date.parse(left.publishedAt), - right.publishedAt === undefined - ? undefined - : Date.parse(right.publishedAt), - direction, - ) - : compareOptionalNumbers( - left.installs ?? undefined, - right.installs ?? undefined, - direction, - ); + sort === "name" + ? left.displayName.localeCompare(right.displayName) * + (direction === "asc" ? 1 : -1) + : sort === "recently-added" + ? compareOptionalNumbers( + left.publishedAt === undefined + ? undefined + : Date.parse(left.publishedAt), + right.publishedAt === undefined + ? undefined + : Date.parse(right.publishedAt), + direction, + ) + : compareOptionalNumbers( + left.installs ?? undefined, + right.installs ?? undefined, + direction, + ); if (sortResult !== 0) return sortResult; const nameResult = left.displayName.localeCompare(right.displayName); return nameResult || left.entryId.localeCompare(right.entryId); diff --git a/apps/app/src/components/plugin/management/usePluginCollectionParams.ts b/apps/app/src/components/plugin/management/usePluginCollectionParams.ts new file mode 100644 index 00000000000..5d6265f5c4a --- /dev/null +++ b/apps/app/src/components/plugin/management/usePluginCollectionParams.ts @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { + PluginBrowseSort, + PluginBrowseSortDirection, +} from "./plugin-browse-discovery"; + +export function usePluginCollectionParams() { + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get("query") ?? ""; + const rawSort = searchParams.get("sort"); + const requestedSort: PluginBrowseSort | null = + rawSort === "name" || + rawSort === "recently-added" || + rawSort === "most-installed" + ? rawSort + : null; + const rawDirection = searchParams.get("direction"); + const sortDirection: PluginBrowseSortDirection = + rawDirection === "asc" || rawDirection === "desc" + ? rawDirection + : requestedSort === "name" + ? "asc" + : "desc"; + const selectedCategories = useMemo( + () => + searchParams.get("shelf")?.startsWith("category:") + ? [] + : searchParams.getAll("category"), + [searchParams], + ); + const changeSearchParams = useCallback( + (change: (next: URLSearchParams) => void, replace = true) => { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + change(next); + return next; + }, + { replace }, + ); + }, + [setSearchParams], + ); + return { + searchParams, + query, + requestedSort, + sortDirection, + selectedCategories, + changeSearchParams, + }; +} diff --git a/apps/app/src/components/plugin/plugin-provenance.ts b/apps/app/src/components/plugin/plugin-provenance.ts index d1a170827fa..6fa8b3e0bbd 100644 --- a/apps/app/src/components/plugin/plugin-provenance.ts +++ b/apps/app/src/components/plugin/plugin-provenance.ts @@ -1,25 +1,26 @@ import type { PluginListItem } from "@/hooks/queries/plugin-settings-queries"; -const USER_FILTER_ID = "user"; +const DIRECT_INSTALL_FILTER_ID = "user"; -export function pluginPublisherFilterId(plugin: PluginListItem): string { +export function pluginSourceFilterId(plugin: PluginListItem): string { return plugin.publisherLabel === null - ? USER_FILTER_ID + ? DIRECT_INSTALL_FILTER_ID : `publisher:${plugin.publisherLabel}`; } -export function pluginPublisherFilterOptions( +export function pluginSourceFilterOptions( plugins: readonly PluginListItem[], ): { id: string; label: string }[] { const publishers = new Set(); - let hasUserPlugin = false; + let hasDirectInstall = false; for (const plugin of plugins) { - if (plugin.publisherLabel === null) hasUserPlugin = true; + if (plugin.publisherLabel === null) hasDirectInstall = true; else publishers.add(plugin.publisherLabel); } const options = [...publishers] .sort((left, right) => left.localeCompare(right)) .map((label) => ({ id: `publisher:${label}`, label })); - if (hasUserPlugin) options.push({ id: USER_FILTER_ID, label: "User" }); + if (hasDirectInstall) + options.push({ id: DIRECT_INSTALL_FILTER_ID, label: "Direct install" }); return options; } diff --git a/apps/app/src/components/plugin/plugins-collection-copy.ts b/apps/app/src/components/plugin/plugins-collection-copy.ts index 0ad964f5ad2..99ea0ba298e 100644 --- a/apps/app/src/components/plugin/plugins-collection-copy.ts +++ b/apps/app/src/components/plugin/plugins-collection-copy.ts @@ -1,5 +1,5 @@ export const PLUGINS_BROWSE_DESCRIPTION = - "Plugins add app surfaces, commands, services, schedules, and skills to bb. Install an official plugin, or describe your own and build it from a prompt."; + "Discover plugins for bb. Install one, or describe your own and build it from a prompt."; export const PLUGINS_INSTALLED_DESCRIPTION = "The plugins installed on this bb host. Turn one on or off, apply updates, or open it for settings and details."; diff --git a/apps/app/src/components/tools/PluginDetail.tsx b/apps/app/src/components/tools/PluginDetail.tsx index 9310bc9a43d..8755b2ffe05 100644 --- a/apps/app/src/components/tools/PluginDetail.tsx +++ b/apps/app/src/components/tools/PluginDetail.tsx @@ -52,7 +52,6 @@ import { PluginDetailTable, } from "@/components/tools/plugin-detail-table"; import { PluginBannerBar } from "@/components/tools/plugin-detail-banner"; -import { ProvenancePill } from "@/components/tools/ProvenancePill"; import { usePluginSource, type PluginCatalogSearchEntry, @@ -66,13 +65,6 @@ import { import { usePluginSlots } from "@/lib/plugin-slots"; import { useClipboardCopy } from "@/lib/clipboard"; -export function PluginProvenancePill({ plugin }: { plugin: PluginListItem }) { - const label = plugin.publisherLabel; - return label === null || label === "BB Official" ? null : ( - - ); -} - export function pluginIsLocalSource(plugin: PluginListItem): boolean { return plugin.source.startsWith("path:"); } @@ -330,12 +322,9 @@ export function PluginDetail({ leading={} title={pluginName} titleMeta={ - - - {catalogEntry === undefined ? null : ( - - )} - + catalogEntry === undefined ? null : ( + + ) } metadata={
diff --git a/apps/app/src/components/tools/PluginsAndSkillsDetailStates.stories.tsx b/apps/app/src/components/tools/PluginsAndSkillsDetailStates.stories.tsx index 2d0c26fe264..16ab522fa71 100644 --- a/apps/app/src/components/tools/PluginsAndSkillsDetailStates.stories.tsx +++ b/apps/app/src/components/tools/PluginsAndSkillsDetailStates.stories.tsx @@ -30,7 +30,6 @@ import { CatalogPluginDetailBanner, PluginDetail, PluginDetailBanners, - PluginProvenancePill, } from "@/components/tools/PluginDetail"; import { ProviderLogo, @@ -1165,16 +1164,6 @@ export function ResourceControlStates() { title="Owned detail-page badges" description="Badges appear only when provenance changes how the resource should be understood. Ordinary owned resources stay unlabelled in their detail-page stories." > - } - meaning="Published by bb and installed from the catalog." - /> - } - meaning="Ships with bb. The same badge communicates publisher; lifecycle differences remain in metadata and actions." - /> { + const results = await checkPluginUpdates( + fetch, + pluginId === null ? {} : { id: pluginId }, + ); + await invalidatePluginList({ queryClient }); + return results; + }, + enabled: options.enabled, + staleTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }); +} + export interface PluginUpdateResult { applied: boolean; outcome: SdkPluginApplyUpdateResult["outcome"]; diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 4a3a124fcc6..30c686a8c61 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -1247,6 +1247,10 @@ export function allPluginSettingsQueryKeyPrefix() { return [PLUGIN_SDK_SETTINGS_QUERY_KEY] as const; } +export function pluginUpdateCheckQueryKey(pluginId: string | null) { + return ["plugins", "update-check", pluginId] as const; +} + export function pluginSourceQueryKey(pluginId: string) { return [PLUGIN_SOURCE_QUERY_KEY, pluginId] as const; } diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index 3a5bd68d31d..a78fbfaa371 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -39,7 +39,6 @@ import { CatalogPluginDetailBanner, PluginDetail, PluginDetailBanners, - PluginProvenancePill, pluginFrontendDiagnosticRequiresFailureBanner, } from "@/components/tools/PluginDetail"; import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; @@ -262,30 +261,6 @@ describe("PluginDetail official catalog lifecycle", () => { ).toBe(true); }); - it("omits a provenance badge for default direct and local sources", () => { - const directPlugin: PluginListItem = { - ...GITHUB_PLUGIN, - source: "npm:@example/github@^1.0.0", - provenance: "direct", - catalogEntryId: null, - publisherLabel: null, - }; - const { container, rerender } = render( - , - ); - expect(container.textContent).toBe(""); - - rerender( - , - ); - expect(container.textContent).toBe(""); - }); - it("keeps catalog provenance and release management in the unified detail taxonomy", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText } }); @@ -852,6 +827,18 @@ describe("BB Official plugin detail routing", () => { "Description from the installed catalog.", ); }); + const title = screen.getByRole("heading", { name: "GitHub", level: 1 }); + const header = title.parentElement?.parentElement; + if (!header) throw new Error("Plugin header missing"); + expect(within(header).queryByText("Partner Catalog")).toBeNull(); + const source = screen.getByRole("link", { + name: /github.com\/example\/installed-catalog-plugin/u, + }); + expect(source.querySelector('[data-icon="GithubLogo"]')).not.toBeNull(); + expect( + within(header).getByRole("link", { name: "Installed publisher" }), + ).toBeTruthy(); + expect(screen.getByText("Partner Catalog")).toBeTruthy(); }); it("uses installed metadata when its catalog entry is unavailable", async () => { @@ -1076,7 +1063,7 @@ describe("BB Official plugin detail routing", () => { await waitFor(() => { expect(screen.getByTestId("route-path").textContent).toBe("/plugins"); expect(screen.getByTestId("route-search").textContent).toBe( - installed ? "?view=installed" : "", + installed ? "?view=installed&query=Local+GitHub" : "", ); expect(document.activeElement).toBe(card); if (installed) expect(search).toHaveProperty("value", "Local GitHub"); diff --git a/packages/plugin-registry/r/icon-extended.json b/packages/plugin-registry/r/icon-extended.json index 4650a8760b9..568479c7723 100644 --- a/packages/plugin-registry/r/icon-extended.json +++ b/packages/plugin-registry/r/icon-extended.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/components/ui/icon-extended.tsx", - "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport AiBrain01Icon from \"@hugeicons/core-free-icons/AiBrain01Icon\";\nimport AiBrowserIcon from \"@hugeicons/core-free-icons/AiBrowserIcon\";\nimport AiContentGenerator01Icon from \"@hugeicons/core-free-icons/AiContentGenerator01Icon\";\nimport ArrowDown02Icon from \"@hugeicons/core-free-icons/ArrowDown02Icon\";\nimport ArrowDownDoubleIcon from \"@hugeicons/core-free-icons/ArrowDownDoubleIcon\";\nimport ArrowMoveDownLeftIcon from \"@hugeicons/core-free-icons/ArrowMoveDownLeftIcon\";\nimport ArrowMoveDownRightIcon from \"@hugeicons/core-free-icons/ArrowMoveDownRightIcon\";\nimport ArrowReloadHorizontalIcon from \"@hugeicons/core-free-icons/ArrowReloadHorizontalIcon\";\nimport ArrowRight02Icon from \"@hugeicons/core-free-icons/ArrowRight02Icon\";\nimport ArrowTurnBackwardIcon from \"@hugeicons/core-free-icons/ArrowTurnBackwardIcon\";\nimport ArrowTurnForwardIcon from \"@hugeicons/core-free-icons/ArrowTurnForwardIcon\";\nimport ArrowUp01Icon from \"@hugeicons/core-free-icons/ArrowUp01Icon\";\nimport ArrowUp02Icon from \"@hugeicons/core-free-icons/ArrowUp02Icon\";\nimport ArrowUpDoubleIcon from \"@hugeicons/core-free-icons/ArrowUpDoubleIcon\";\nimport ArrowLeft02Icon from \"@hugeicons/core-free-icons/ArrowLeft02Icon\";\nimport ArrowUpDownIcon from \"@hugeicons/core-free-icons/ArrowUpDownIcon\";\nimport ArrowUpRight01Icon from \"@hugeicons/core-free-icons/ArrowUpRight01Icon\";\nimport AttachmentIcon from \"@hugeicons/core-free-icons/AttachmentIcon\";\nimport BellDotIcon from \"@hugeicons/core-free-icons/BellDotIcon\";\nimport Book02Icon from \"@hugeicons/core-free-icons/Book02Icon\";\nimport BrainIcon from \"@hugeicons/core-free-icons/BrainIcon\";\nimport BrowserIcon from \"@hugeicons/core-free-icons/BrowserIcon\";\nimport Calendar03Icon from \"@hugeicons/core-free-icons/Calendar03Icon\";\nimport CalendarCheckOut02Icon from \"@hugeicons/core-free-icons/CalendarCheckOut02Icon\";\nimport ChartColumnIcon from \"@hugeicons/core-free-icons/ChartColumnIcon\";\nimport CircleArrowShrink01Icon from \"@hugeicons/core-free-icons/CircleArrowShrink01Icon\";\nimport CleanIcon from \"@hugeicons/core-free-icons/CleanIcon\";\nimport Clock01Icon from \"@hugeicons/core-free-icons/Clock01Icon\";\nimport CloudIcon from \"@hugeicons/core-free-icons/CloudIcon\";\nimport CloudOffIcon from \"@hugeicons/core-free-icons/CloudOffIcon\";\nimport Coffee02Icon from \"@hugeicons/core-free-icons/Coffee02Icon\";\nimport CollapseIcon from \"@hugeicons/core-free-icons/CollapseIcon\";\nimport DashedLine02Icon from \"@hugeicons/core-free-icons/DashedLine02Icon\";\nimport DateTimeIcon from \"@hugeicons/core-free-icons/DateTimeIcon\";\nimport DiscordIcon from \"@hugeicons/core-free-icons/DiscordIcon\";\nimport DragDropHorizontalIcon from \"@hugeicons/core-free-icons/DragDropHorizontalIcon\";\nimport DragDropVerticalIcon from \"@hugeicons/core-free-icons/DragDropVerticalIcon\";\nimport Edit04Icon from \"@hugeicons/core-free-icons/Edit04Icon\";\nimport ElectricPlugsIcon from \"@hugeicons/core-free-icons/ElectricPlugsIcon\";\nimport ExpandIcon from \"@hugeicons/core-free-icons/ExpandIcon\";\nimport File01Icon from \"@hugeicons/core-free-icons/File01Icon\";\nimport FileAttachmentIcon from \"@hugeicons/core-free-icons/FileAttachmentIcon\";\nimport FileEmpty02Icon from \"@hugeicons/core-free-icons/FileEmpty02Icon\";\nimport FileQuestionMarkIcon from \"@hugeicons/core-free-icons/FileQuestionMarkIcon\";\nimport Folder02Icon from \"@hugeicons/core-free-icons/Folder02Icon\";\nimport FolderEditIcon from \"@hugeicons/core-free-icons/FolderEditIcon\";\nimport FolderRemoveIcon from \"@hugeicons/core-free-icons/FolderRemoveIcon\";\nimport GitBranchIcon from \"@hugeicons/core-free-icons/GitBranchIcon\";\nimport GitForkIcon from \"@hugeicons/core-free-icons/GitForkIcon\";\nimport GithubIcon from \"@hugeicons/core-free-icons/GithubIcon\";\nimport GitMergeIcon from \"@hugeicons/core-free-icons/GitMergeIcon\";\nimport GitPullRequestArrow from \"@hugeicons/core-free-icons/GitPullRequestIcon\";\nimport GitPullRequestClosedIcon from \"@hugeicons/core-free-icons/GitPullRequestClosedIcon\";\nimport GitPullRequestDraftIcon from \"@hugeicons/core-free-icons/GitPullRequestDraftIcon\";\nimport GitPullRequestIcon from \"@hugeicons/core-free-icons/GitPullRequestIcon\";\nimport GridViewIcon from \"@hugeicons/core-free-icons/GridViewIcon\";\nimport InternetIcon from \"@hugeicons/core-free-icons/InternetIcon\";\nimport LaptopIcon from \"@hugeicons/core-free-icons/LaptopIcon\";\nimport Layers01Icon from \"@hugeicons/core-free-icons/Layers01Icon\";\nimport LayoutTwoColumnIcon from \"@hugeicons/core-free-icons/Layout2ColumnIcon\";\nimport LayoutTwoRowIcon from \"@hugeicons/core-free-icons/Layout2RowIcon\";\nimport LimitationIcon from \"@hugeicons/core-free-icons/LimitationIcon\";\nimport LinkSquare02Icon from \"@hugeicons/core-free-icons/LinkSquare02Icon\";\nimport ListEndIcon from \"@hugeicons/core-free-icons/ListEndIcon\";\nimport ListViewIcon from \"@hugeicons/core-free-icons/ListViewIcon\";\nimport LockIcon from \"@hugeicons/core-free-icons/LockIcon\";\nimport Mail02Icon from \"@hugeicons/core-free-icons/Mail02Icon\";\nimport MailOpen01Icon from \"@hugeicons/core-free-icons/MailOpen01Icon\";\nimport Menu02Icon from \"@hugeicons/core-free-icons/Menu02Icon\";\nimport MessageAdd02Icon from \"@hugeicons/core-free-icons/MessageAdd02Icon\";\nimport Mic02Icon from \"@hugeicons/core-free-icons/Mic02Icon\";\nimport MinusSignIcon from \"@hugeicons/core-free-icons/MinusSignIcon\";\nimport MoveToIcon from \"@hugeicons/core-free-icons/MoveToIcon\";\nimport News01Icon from \"@hugeicons/core-free-icons/News01Icon\";\nimport PackageReceiveIcon from \"@hugeicons/core-free-icons/PackageReceiveIcon\";\nimport PauseIcon from \"@hugeicons/core-free-icons/PauseIcon\";\nimport PinIcon from \"@hugeicons/core-free-icons/PinIcon\";\nimport PinOffIcon from \"@hugeicons/core-free-icons/PinOffIcon\";\nimport PlayIcon from \"@hugeicons/core-free-icons/PlayIcon\";\nimport Plug02Icon from \"@hugeicons/core-free-icons/Plug02Icon\";\nimport PlusMinusSquare01Icon from \"@hugeicons/core-free-icons/PlusMinusSquare01Icon\";\nimport PlusSignIcon from \"@hugeicons/core-free-icons/PlusSignIcon\";\nimport PuzzleIcon from \"@hugeicons/core-free-icons/PuzzleIcon\";\nimport Refresh01Icon from \"@hugeicons/core-free-icons/Refresh01Icon\";\nimport RepeatIcon from \"@hugeicons/core-free-icons/RepeatIcon\";\nimport SecurityCheckIcon from \"@hugeicons/core-free-icons/SecurityCheckIcon\";\nimport SentIcon from \"@hugeicons/core-free-icons/SentIcon\";\nimport SidebarBottomIcon from \"@hugeicons/core-free-icons/SidebarBottomIcon\";\nimport SidebarRightIcon from \"@hugeicons/core-free-icons/SidebarRightIcon\";\nimport SmartPhone01Icon from \"@hugeicons/core-free-icons/SmartPhone01Icon\";\nimport Sorting01Icon from \"@hugeicons/core-free-icons/Sorting01Icon\";\nimport SquareIcon from \"@hugeicons/core-free-icons/SquareIcon\";\nimport SquareUnlock02Icon from \"@hugeicons/core-free-icons/SquareUnlock02Icon\";\nimport StarIcon from \"@hugeicons/core-free-icons/StarIcon\";\nimport TestTube01Icon from \"@hugeicons/core-free-icons/TestTube01Icon\";\nimport TextWrapIcon from \"@hugeicons/core-free-icons/TextWrapIcon\";\nimport TimeScheduleIcon from \"@hugeicons/core-free-icons/TimeScheduleIcon\";\nimport Unarchive03Icon from \"@hugeicons/core-free-icons/Unarchive03Icon\";\nimport UserIcon from \"@hugeicons/core-free-icons/UserIcon\";\nimport ViewIcon from \"@hugeicons/core-free-icons/ViewIcon\";\nimport ViewOffIcon from \"@hugeicons/core-free-icons/ViewOffIcon\";\nimport ZoomInAreaIcon from \"@hugeicons/core-free-icons/ZoomInAreaIcon\";\nimport ZoomOutAreaIcon from \"@hugeicons/core-free-icons/ZoomOutAreaIcon\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nconst DiscordLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nconst GithubLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrain01: AiBrain01Icon,\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowLeft: ArrowLeft02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n BellDot: BellDotIcon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DiscordLogo: DiscordLogoIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n GithubLogo: GithubLogoIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n Limitation: LimitationIcon,\n ListEnd: ListEndIcon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minus: MinusSignIcon,\n Minimize2: CollapseIcon,\n MoveTo: MoveToIcon,\n NewTab: DashedLine02Icon,\n News01: News01Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plug02: Plug02Icon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", + "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport AiBrain01Icon from \"@hugeicons/core-free-icons/AiBrain01Icon\";\nimport AiBrowserIcon from \"@hugeicons/core-free-icons/AiBrowserIcon\";\nimport AiContentGenerator01Icon from \"@hugeicons/core-free-icons/AiContentGenerator01Icon\";\nimport ArrowDown02Icon from \"@hugeicons/core-free-icons/ArrowDown02Icon\";\nimport ArrowDownDoubleIcon from \"@hugeicons/core-free-icons/ArrowDownDoubleIcon\";\nimport ArrowLeft02Icon from \"@hugeicons/core-free-icons/ArrowLeft02Icon\";\nimport ArrowMoveDownLeftIcon from \"@hugeicons/core-free-icons/ArrowMoveDownLeftIcon\";\nimport ArrowMoveDownRightIcon from \"@hugeicons/core-free-icons/ArrowMoveDownRightIcon\";\nimport ArrowReloadHorizontalIcon from \"@hugeicons/core-free-icons/ArrowReloadHorizontalIcon\";\nimport ArrowRight02Icon from \"@hugeicons/core-free-icons/ArrowRight02Icon\";\nimport ArrowTurnBackwardIcon from \"@hugeicons/core-free-icons/ArrowTurnBackwardIcon\";\nimport ArrowTurnForwardIcon from \"@hugeicons/core-free-icons/ArrowTurnForwardIcon\";\nimport ArrowUp01Icon from \"@hugeicons/core-free-icons/ArrowUp01Icon\";\nimport ArrowUp02Icon from \"@hugeicons/core-free-icons/ArrowUp02Icon\";\nimport ArrowUpDoubleIcon from \"@hugeicons/core-free-icons/ArrowUpDoubleIcon\";\nimport ArrowUpDownIcon from \"@hugeicons/core-free-icons/ArrowUpDownIcon\";\nimport ArrowUpRight01Icon from \"@hugeicons/core-free-icons/ArrowUpRight01Icon\";\nimport AttachmentIcon from \"@hugeicons/core-free-icons/AttachmentIcon\";\nimport BellDotIcon from \"@hugeicons/core-free-icons/BellDotIcon\";\nimport Book02Icon from \"@hugeicons/core-free-icons/Book02Icon\";\nimport BrainIcon from \"@hugeicons/core-free-icons/BrainIcon\";\nimport BrowserIcon from \"@hugeicons/core-free-icons/BrowserIcon\";\nimport Calendar03Icon from \"@hugeicons/core-free-icons/Calendar03Icon\";\nimport CalendarCheckOut02Icon from \"@hugeicons/core-free-icons/CalendarCheckOut02Icon\";\nimport ChartColumnIcon from \"@hugeicons/core-free-icons/ChartColumnIcon\";\nimport CircleArrowShrink01Icon from \"@hugeicons/core-free-icons/CircleArrowShrink01Icon\";\nimport CleanIcon from \"@hugeicons/core-free-icons/CleanIcon\";\nimport Clock01Icon from \"@hugeicons/core-free-icons/Clock01Icon\";\nimport ClockArrowDownIcon from \"@hugeicons/core-free-icons/ClockArrowDownIcon\";\nimport ClockArrowUpIcon from \"@hugeicons/core-free-icons/ClockArrowUpIcon\";\nimport CloudIcon from \"@hugeicons/core-free-icons/CloudIcon\";\nimport CloudOffIcon from \"@hugeicons/core-free-icons/CloudOffIcon\";\nimport Coffee02Icon from \"@hugeicons/core-free-icons/Coffee02Icon\";\nimport CollapseIcon from \"@hugeicons/core-free-icons/CollapseIcon\";\nimport DashedLine02Icon from \"@hugeicons/core-free-icons/DashedLine02Icon\";\nimport DateTimeIcon from \"@hugeicons/core-free-icons/DateTimeIcon\";\nimport DiscordIcon from \"@hugeicons/core-free-icons/DiscordIcon\";\nimport DragDropHorizontalIcon from \"@hugeicons/core-free-icons/DragDropHorizontalIcon\";\nimport DragDropVerticalIcon from \"@hugeicons/core-free-icons/DragDropVerticalIcon\";\nimport Edit04Icon from \"@hugeicons/core-free-icons/Edit04Icon\";\nimport ElectricPlugsIcon from \"@hugeicons/core-free-icons/ElectricPlugsIcon\";\nimport ExpandIcon from \"@hugeicons/core-free-icons/ExpandIcon\";\nimport File01Icon from \"@hugeicons/core-free-icons/File01Icon\";\nimport FileAttachmentIcon from \"@hugeicons/core-free-icons/FileAttachmentIcon\";\nimport FileEmpty02Icon from \"@hugeicons/core-free-icons/FileEmpty02Icon\";\nimport FileQuestionMarkIcon from \"@hugeicons/core-free-icons/FileQuestionMarkIcon\";\nimport Folder02Icon from \"@hugeicons/core-free-icons/Folder02Icon\";\nimport FolderEditIcon from \"@hugeicons/core-free-icons/FolderEditIcon\";\nimport FolderRemoveIcon from \"@hugeicons/core-free-icons/FolderRemoveIcon\";\nimport GitBranchIcon from \"@hugeicons/core-free-icons/GitBranchIcon\";\nimport GitForkIcon from \"@hugeicons/core-free-icons/GitForkIcon\";\nimport GitMergeIcon from \"@hugeicons/core-free-icons/GitMergeIcon\";\nimport GitPullRequestArrow from \"@hugeicons/core-free-icons/GitPullRequestIcon\";\nimport GitPullRequestClosedIcon from \"@hugeicons/core-free-icons/GitPullRequestClosedIcon\";\nimport GitPullRequestDraftIcon from \"@hugeicons/core-free-icons/GitPullRequestDraftIcon\";\nimport GitPullRequestIcon from \"@hugeicons/core-free-icons/GitPullRequestIcon\";\nimport GithubIcon from \"@hugeicons/core-free-icons/GithubIcon\";\nimport GridViewIcon from \"@hugeicons/core-free-icons/GridViewIcon\";\nimport InternetIcon from \"@hugeicons/core-free-icons/InternetIcon\";\nimport LaptopIcon from \"@hugeicons/core-free-icons/LaptopIcon\";\nimport Layers01Icon from \"@hugeicons/core-free-icons/Layers01Icon\";\nimport LayoutTwoColumnIcon from \"@hugeicons/core-free-icons/Layout2ColumnIcon\";\nimport LayoutTwoRowIcon from \"@hugeicons/core-free-icons/Layout2RowIcon\";\nimport LimitationIcon from \"@hugeicons/core-free-icons/LimitationIcon\";\nimport LinkSquare02Icon from \"@hugeicons/core-free-icons/LinkSquare02Icon\";\nimport ListEndIcon from \"@hugeicons/core-free-icons/ListEndIcon\";\nimport ListViewIcon from \"@hugeicons/core-free-icons/ListViewIcon\";\nimport LockIcon from \"@hugeicons/core-free-icons/LockIcon\";\nimport Mail02Icon from \"@hugeicons/core-free-icons/Mail02Icon\";\nimport MailOpen01Icon from \"@hugeicons/core-free-icons/MailOpen01Icon\";\nimport Menu02Icon from \"@hugeicons/core-free-icons/Menu02Icon\";\nimport MessageAdd02Icon from \"@hugeicons/core-free-icons/MessageAdd02Icon\";\nimport Mic02Icon from \"@hugeicons/core-free-icons/Mic02Icon\";\nimport MinusSignIcon from \"@hugeicons/core-free-icons/MinusSignIcon\";\nimport MoveToIcon from \"@hugeicons/core-free-icons/MoveToIcon\";\nimport News01Icon from \"@hugeicons/core-free-icons/News01Icon\";\nimport PackageReceiveIcon from \"@hugeicons/core-free-icons/PackageReceiveIcon\";\nimport PauseIcon from \"@hugeicons/core-free-icons/PauseIcon\";\nimport PinIcon from \"@hugeicons/core-free-icons/PinIcon\";\nimport PinOffIcon from \"@hugeicons/core-free-icons/PinOffIcon\";\nimport PlayIcon from \"@hugeicons/core-free-icons/PlayIcon\";\nimport Plug02Icon from \"@hugeicons/core-free-icons/Plug02Icon\";\nimport PlusMinusSquare01Icon from \"@hugeicons/core-free-icons/PlusMinusSquare01Icon\";\nimport PlusSignIcon from \"@hugeicons/core-free-icons/PlusSignIcon\";\nimport PuzzleIcon from \"@hugeicons/core-free-icons/PuzzleIcon\";\nimport Refresh01Icon from \"@hugeicons/core-free-icons/Refresh01Icon\";\nimport RepeatIcon from \"@hugeicons/core-free-icons/RepeatIcon\";\nimport SecurityCheckIcon from \"@hugeicons/core-free-icons/SecurityCheckIcon\";\nimport SentIcon from \"@hugeicons/core-free-icons/SentIcon\";\nimport SidebarBottomIcon from \"@hugeicons/core-free-icons/SidebarBottomIcon\";\nimport SidebarRightIcon from \"@hugeicons/core-free-icons/SidebarRightIcon\";\nimport SmartPhone01Icon from \"@hugeicons/core-free-icons/SmartPhone01Icon\";\nimport Sorting01Icon from \"@hugeicons/core-free-icons/Sorting01Icon\";\nimport SortingAZ02Icon from \"@hugeicons/core-free-icons/SortingAZ02Icon\";\nimport SortingNineOneIcon from \"@hugeicons/core-free-icons/SortingNineOneIcon\";\nimport SortingOneNineIcon from \"@hugeicons/core-free-icons/SortingOneNineIcon\";\nimport SortingZA01Icon from \"@hugeicons/core-free-icons/SortingZA01Icon\";\nimport SquareIcon from \"@hugeicons/core-free-icons/SquareIcon\";\nimport SquareUnlock02Icon from \"@hugeicons/core-free-icons/SquareUnlock02Icon\";\nimport StarIcon from \"@hugeicons/core-free-icons/StarIcon\";\nimport TestTube01Icon from \"@hugeicons/core-free-icons/TestTube01Icon\";\nimport TextWrapIcon from \"@hugeicons/core-free-icons/TextWrapIcon\";\nimport TimeScheduleIcon from \"@hugeicons/core-free-icons/TimeScheduleIcon\";\nimport Unarchive03Icon from \"@hugeicons/core-free-icons/Unarchive03Icon\";\nimport UserIcon from \"@hugeicons/core-free-icons/UserIcon\";\nimport ViewIcon from \"@hugeicons/core-free-icons/ViewIcon\";\nimport ViewOffIcon from \"@hugeicons/core-free-icons/ViewOffIcon\";\nimport ZoomInAreaIcon from \"@hugeicons/core-free-icons/ZoomInAreaIcon\";\nimport ZoomOutAreaIcon from \"@hugeicons/core-free-icons/ZoomOutAreaIcon\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nconst DiscordLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nconst GithubLogoIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943\",\n fill: \"currentColor\",\n key: \"0\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrain01: AiBrain01Icon,\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowLeft: ArrowLeft02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n BellDot: BellDotIcon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n ClockArrowUp: ClockArrowUpIcon,\n ClockArrowDown: ClockArrowDownIcon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DiscordLogo: DiscordLogoIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n GithubLogo: GithubLogoIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n Limitation: LimitationIcon,\n ListEnd: ListEndIcon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minus: MinusSignIcon,\n Minimize2: CollapseIcon,\n MoveTo: MoveToIcon,\n NewTab: DashedLine02Icon,\n News01: News01Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plug02: Plug02Icon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n SortingAZ02: SortingAZ02Icon,\n SortingZA01: SortingZA01Icon,\n SortingOneNine: SortingOneNineIcon,\n SortingNineOne: SortingNineOneIcon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", "type": "registry:ui", "target": "components/ui/icon-extended.tsx" } diff --git a/packages/plugin-registry/r/icon-registry.json b/packages/plugin-registry/r/icon-registry.json index fb94ab56153..e6ef7eefda2 100644 --- a/packages/plugin-registry/r/icon-registry.json +++ b/packages/plugin-registry/r/icon-registry.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/components/ui/icon-registry.ts", - "content": "import type { ComponentType } from \"react\";\nimport type { IconSvgElement } from \"@hugeicons/react\";\n\nexport const EXTENDED_ICON_NAMES = [\n \"AiBrain01\",\n \"AiBrowser\",\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"BellDot\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DiscordLogo\",\n \"DateTime\",\n \"Github\",\n \"GithubLogo\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"Limitation\",\n \"ListEnd\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minus\",\n \"Minimize2\",\n \"MoveTo\",\n \"NewTab\",\n \"News01\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plug02\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\ninterface AppIconDefinition {\n component: ComponentType<{ className?: string }>;\n key: string;\n}\n\nlet appIcons: ReadonlyMap = new Map();\nconst appIconListeners = new Set<() => void>();\n\nexport function setAppIcons(\n next: ReadonlyMap,\n): void {\n appIcons = next;\n for (const listener of appIconListeners) listener();\n}\n\nexport function getAppIcon(name: string): AppIconDefinition | undefined {\n return appIcons.get(name);\n}\n\nexport function subscribeAppIcons(listener: () => void): () => void {\n appIconListeners.add(listener);\n return () => {\n appIconListeners.delete(listener);\n };\n}\n\nlet pluginAssetIcons: ReadonlyMap = new Map();\nconst pluginAssetIconListeners = new Set<() => void>();\n\nexport function setPluginAssetIcons(next: ReadonlyMap): void {\n pluginAssetIcons = next;\n for (const listener of pluginAssetIconListeners) listener();\n}\n\nexport function getPluginAssetIcon(glyph: string): string | undefined {\n return pluginAssetIcons.get(glyph);\n}\n\nexport function subscribePluginAssetIcons(listener: () => void): () => void {\n pluginAssetIconListeners.add(listener);\n return () => {\n pluginAssetIconListeners.delete(listener);\n };\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type { IconSvgElement } from \"@hugeicons/react\";\n\nexport const EXTENDED_ICON_NAMES = [\n \"AiBrain01\",\n \"AiBrowser\",\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"BellDot\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"ClockArrowUp\",\n \"ClockArrowDown\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DiscordLogo\",\n \"DateTime\",\n \"Github\",\n \"GithubLogo\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"Limitation\",\n \"ListEnd\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minus\",\n \"Minimize2\",\n \"MoveTo\",\n \"NewTab\",\n \"News01\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plug02\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"SortingAZ02\",\n \"SortingZA01\",\n \"SortingOneNine\",\n \"SortingNineOne\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\ninterface AppIconDefinition {\n component: ComponentType<{ className?: string }>;\n key: string;\n}\n\nlet appIcons: ReadonlyMap = new Map();\nconst appIconListeners = new Set<() => void>();\n\nexport function setAppIcons(\n next: ReadonlyMap,\n): void {\n appIcons = next;\n for (const listener of appIconListeners) listener();\n}\n\nexport function getAppIcon(name: string): AppIconDefinition | undefined {\n return appIcons.get(name);\n}\n\nexport function subscribeAppIcons(listener: () => void): () => void {\n appIconListeners.add(listener);\n return () => {\n appIconListeners.delete(listener);\n };\n}\n\nlet pluginAssetIcons: ReadonlyMap = new Map();\nconst pluginAssetIconListeners = new Set<() => void>();\n\nexport function setPluginAssetIcons(next: ReadonlyMap): void {\n pluginAssetIcons = next;\n for (const listener of pluginAssetIconListeners) listener();\n}\n\nexport function getPluginAssetIcon(glyph: string): string | undefined {\n return pluginAssetIcons.get(glyph);\n}\n\nexport function subscribePluginAssetIcons(listener: () => void): () => void {\n pluginAssetIconListeners.add(listener);\n return () => {\n pluginAssetIconListeners.delete(listener);\n };\n}\n", "type": "registry:ui", "target": "components/ui/icon-registry.ts" } diff --git a/packages/shared-ui/src/components/ui/icon-extended.tsx b/packages/shared-ui/src/components/ui/icon-extended.tsx index 4881027b4d9..79c59de8bfe 100644 --- a/packages/shared-ui/src/components/ui/icon-extended.tsx +++ b/packages/shared-ui/src/components/ui/icon-extended.tsx @@ -4,6 +4,7 @@ import AiBrowserIcon from "@hugeicons/core-free-icons/AiBrowserIcon"; import AiContentGenerator01Icon from "@hugeicons/core-free-icons/AiContentGenerator01Icon"; import ArrowDown02Icon from "@hugeicons/core-free-icons/ArrowDown02Icon"; import ArrowDownDoubleIcon from "@hugeicons/core-free-icons/ArrowDownDoubleIcon"; +import ArrowLeft02Icon from "@hugeicons/core-free-icons/ArrowLeft02Icon"; import ArrowMoveDownLeftIcon from "@hugeicons/core-free-icons/ArrowMoveDownLeftIcon"; import ArrowMoveDownRightIcon from "@hugeicons/core-free-icons/ArrowMoveDownRightIcon"; import ArrowReloadHorizontalIcon from "@hugeicons/core-free-icons/ArrowReloadHorizontalIcon"; @@ -13,7 +14,6 @@ import ArrowTurnForwardIcon from "@hugeicons/core-free-icons/ArrowTurnForwardIco import ArrowUp01Icon from "@hugeicons/core-free-icons/ArrowUp01Icon"; import ArrowUp02Icon from "@hugeicons/core-free-icons/ArrowUp02Icon"; import ArrowUpDoubleIcon from "@hugeicons/core-free-icons/ArrowUpDoubleIcon"; -import ArrowLeft02Icon from "@hugeicons/core-free-icons/ArrowLeft02Icon"; import ArrowUpDownIcon from "@hugeicons/core-free-icons/ArrowUpDownIcon"; import ArrowUpRight01Icon from "@hugeicons/core-free-icons/ArrowUpRight01Icon"; import AttachmentIcon from "@hugeicons/core-free-icons/AttachmentIcon"; @@ -27,6 +27,8 @@ import ChartColumnIcon from "@hugeicons/core-free-icons/ChartColumnIcon"; import CircleArrowShrink01Icon from "@hugeicons/core-free-icons/CircleArrowShrink01Icon"; import CleanIcon from "@hugeicons/core-free-icons/CleanIcon"; import Clock01Icon from "@hugeicons/core-free-icons/Clock01Icon"; +import ClockArrowDownIcon from "@hugeicons/core-free-icons/ClockArrowDownIcon"; +import ClockArrowUpIcon from "@hugeicons/core-free-icons/ClockArrowUpIcon"; import CloudIcon from "@hugeicons/core-free-icons/CloudIcon"; import CloudOffIcon from "@hugeicons/core-free-icons/CloudOffIcon"; import Coffee02Icon from "@hugeicons/core-free-icons/Coffee02Icon"; @@ -48,12 +50,12 @@ import FolderEditIcon from "@hugeicons/core-free-icons/FolderEditIcon"; import FolderRemoveIcon from "@hugeicons/core-free-icons/FolderRemoveIcon"; import GitBranchIcon from "@hugeicons/core-free-icons/GitBranchIcon"; import GitForkIcon from "@hugeicons/core-free-icons/GitForkIcon"; -import GithubIcon from "@hugeicons/core-free-icons/GithubIcon"; import GitMergeIcon from "@hugeicons/core-free-icons/GitMergeIcon"; import GitPullRequestArrow from "@hugeicons/core-free-icons/GitPullRequestIcon"; import GitPullRequestClosedIcon from "@hugeicons/core-free-icons/GitPullRequestClosedIcon"; import GitPullRequestDraftIcon from "@hugeicons/core-free-icons/GitPullRequestDraftIcon"; import GitPullRequestIcon from "@hugeicons/core-free-icons/GitPullRequestIcon"; +import GithubIcon from "@hugeicons/core-free-icons/GithubIcon"; import GridViewIcon from "@hugeicons/core-free-icons/GridViewIcon"; import InternetIcon from "@hugeicons/core-free-icons/InternetIcon"; import LaptopIcon from "@hugeicons/core-free-icons/LaptopIcon"; @@ -90,6 +92,10 @@ import SidebarBottomIcon from "@hugeicons/core-free-icons/SidebarBottomIcon"; import SidebarRightIcon from "@hugeicons/core-free-icons/SidebarRightIcon"; import SmartPhone01Icon from "@hugeicons/core-free-icons/SmartPhone01Icon"; import Sorting01Icon from "@hugeicons/core-free-icons/Sorting01Icon"; +import SortingAZ02Icon from "@hugeicons/core-free-icons/SortingAZ02Icon"; +import SortingNineOneIcon from "@hugeicons/core-free-icons/SortingNineOneIcon"; +import SortingOneNineIcon from "@hugeicons/core-free-icons/SortingOneNineIcon"; +import SortingZA01Icon from "@hugeicons/core-free-icons/SortingZA01Icon"; import SquareIcon from "@hugeicons/core-free-icons/SquareIcon"; import SquareUnlock02Icon from "@hugeicons/core-free-icons/SquareUnlock02Icon"; import StarIcon from "@hugeicons/core-free-icons/StarIcon"; @@ -237,6 +243,8 @@ export const EXTENDED_ICON_MAP: ExtendedIconMap = { CircleArrowShrink: CircleArrowShrink01Icon, Clean: CleanIcon, Clock: Clock01Icon, + ClockArrowUp: ClockArrowUpIcon, + ClockArrowDown: ClockArrowDownIcon, Cloud: CloudIcon, CloudOff: CloudOffIcon, Coffee: Coffee02Icon, @@ -308,6 +316,10 @@ export const EXTENDED_ICON_MAP: ExtendedIconMap = { SideChat: MessageAdd02Icon, Smartphone: SmartPhone01Icon, Sort: Sorting01Icon, + SortingAZ02: SortingAZ02Icon, + SortingZA01: SortingZA01Icon, + SortingOneNine: SortingOneNineIcon, + SortingNineOne: SortingNineOneIcon, Square: SquareIcon, SquareUnlock02: SquareUnlock02Icon, Star: StarIcon, diff --git a/packages/shared-ui/src/components/ui/icon-registry.ts b/packages/shared-ui/src/components/ui/icon-registry.ts index 2975c7d86b7..11e5e9d670b 100644 --- a/packages/shared-ui/src/components/ui/icon-registry.ts +++ b/packages/shared-ui/src/components/ui/icon-registry.ts @@ -30,6 +30,8 @@ export const EXTENDED_ICON_NAMES = [ "CircleArrowShrink", "Clean", "Clock", + "ClockArrowUp", + "ClockArrowDown", "Cloud", "CloudOff", "Coffee", @@ -101,6 +103,10 @@ export const EXTENDED_ICON_NAMES = [ "SideChat", "Smartphone", "Sort", + "SortingAZ02", + "SortingZA01", + "SortingOneNine", + "SortingNineOne", "Square", "SquareUnlock02", "Star", diff --git a/packages/shared-ui/src/components/ui/resource-list.tsx b/packages/shared-ui/src/components/ui/resource-list.tsx index bd76b2384d4..ed3cfe05d56 100644 --- a/packages/shared-ui/src/components/ui/resource-list.tsx +++ b/packages/shared-ui/src/components/ui/resource-list.tsx @@ -7,6 +7,10 @@ export { } from "./resource/atoms"; export { useResourceRouteLabel } from "./resource-route-label"; export { + ResourceControlButton, + ResourceMultiSelectMenuItems, + ResourceSortMenuItems, + type ResourceOption, ResourceCreateButton, type ResourceCreateMenuAction, type ResourceCreateTemplateGroup, @@ -14,6 +18,7 @@ export { ResourceMultiSelectMenu, ResourceSortMenu, ResourceToolbar, + ResourceTabDescription, } from "./resource/toolbar"; export { ResourceActionButton, diff --git a/packages/shared-ui/src/components/ui/resource/collection.tsx b/packages/shared-ui/src/components/ui/resource/collection.tsx index f5254c901d9..79122fe9267 100644 --- a/packages/shared-ui/src/components/ui/resource/collection.tsx +++ b/packages/shared-ui/src/components/ui/resource/collection.tsx @@ -45,13 +45,13 @@ export function ResourceCollectionPage({ return (
{} -
+
{description}
{hasModes || actions !== undefined ? ( -
+
{} {toolbar ? ( -
+
{toolbar}
) : null} @@ -172,7 +172,7 @@ export function ResourceCollectionViewport({ viewportRef={viewportRef} viewportProps={{ id: scrollId, - className: cn("overscroll-contain pr-3", contentClassName), + className: cn("overscroll-contain md:pr-3", contentClassName), "data-resource-collection-scroll": true, }} > @@ -180,7 +180,7 @@ export function ResourceCollectionViewport({ {footer ? (
{footer}
diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx index 6a9ad57420b..63e477db2bb 100644 --- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx +++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx @@ -1,5 +1,12 @@ -import { Fragment, useState, type ReactNode } from "react"; -import { Button } from "../button"; +import { + Fragment, + forwardRef, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Button, type ButtonProps } from "../button"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -12,6 +19,7 @@ import { } from "../dropdown-menu"; import { Icon, type IconName } from "../icon"; import { Input } from "../input"; +import { useIsCompactViewport } from "../hooks/use-compact-viewport"; import { Tooltip, TooltipContent, @@ -26,36 +34,287 @@ export function ResourceToolbar({ searchLabel, onSearchChange, controls, + combinedControls, action, + compact = false, + expandSearchOnFocus = false, }: { searchValue: string; searchPlaceholder: string; searchLabel?: string; onSearchChange: (value: string) => void; controls?: ReactNode; + combinedControls?: ReactNode; action?: ReactNode; + compact?: boolean; + expandSearchOnFocus?: boolean; }) { + const isCompactViewport = useIsCompactViewport(); + const toolbarRef = useRef(null); + const controlsRef = useRef(null); + const individualControlsRef = useRef(null); + const searchRef = useRef(null); + const searchInputRef = useRef(null); + const searchButtonRef = useRef(null); + const restoreSearchFocus = useRef(false); + const actionRef = useRef(null); + const [combined, setCombined] = useState(false); + const [searchCondensed, setSearchCondensed] = useState(false); + const [searchExpanded, setSearchExpanded] = useState(false); + const restoreControlFocus = useRef(false); + const combinedRef = useRef(false); + const hasCombinedControls = Boolean(combinedControls); + const showCombined = compact && hasCombinedControls && combined; + const showSearchButton = searchCondensed && !searchExpanded; + + const collapseSearch = () => { + restoreSearchFocus.current = searchCondensed; + searchInputRef.current?.blur(); + setSearchExpanded(false); + }; + + useLayoutEffect(() => { + if (searchExpanded) { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } else if (restoreSearchFocus.current) { + searchButtonRef.current?.focus(); + restoreSearchFocus.current = false; + } + }, [searchExpanded]); + + useLayoutEffect(() => { + const toolbar = toolbarRef.current; + const individualControls = individualControlsRef.current; + const search = searchRef.current; + if (!toolbar || !individualControls || !search || !compact) return; + let previousWidth = 0; + const measure = () => { + const width = toolbar.getBoundingClientRect().width; + if (width === 0) return; + const widthChanged = previousWidth !== width; + previousWidth = width; + if ( + !widthChanged && + controlsRef.current?.querySelector('[aria-haspopup][data-state="open"]') + ) + return; + const gap = Number.parseFloat(getComputedStyle(toolbar).columnGap) || 0; + const searchWidth = Number.parseFloat(getComputedStyle(search).flexBasis); + const actionWidth = actionRef.current?.getBoundingClientRect().width ?? 0; + const requiredWidth = + searchWidth + + individualControls.getBoundingClientRect().width + + actionWidth + + gap * (actionRef.current ? 2 : 1); + const next = hasCombinedControls && width < requiredWidth; + const controlsWidth = next + ? (controlsRef.current + ?.querySelector("[data-resource-combined-controls]") + ?.getBoundingClientRect().width ?? 0) + : individualControls.getBoundingClientRect().width; + setSearchCondensed( + expandSearchOnFocus && + (next || + width - + controlsWidth - + actionWidth - + gap * (actionRef.current ? 2 : 1) < + searchWidth), + ); + if (combinedRef.current === next) return; + restoreControlFocus.current = Boolean( + controlsRef.current?.contains(document.activeElement) || + controlsRef.current?.querySelector('[data-state="open"]'), + ); + combinedRef.current = next; + setCombined(next); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(toolbar); + observer.observe(individualControls); + if (controlsRef.current) observer.observe(controlsRef.current); + if (actionRef.current) observer.observe(actionRef.current); + let frame = 0; + const menuObserver = new MutationObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + }); + if (controlsRef.current) + menuObserver.observe(controlsRef.current, { + attributes: true, + attributeFilter: ["data-state"], + subtree: true, + }); + return () => { + observer.disconnect(); + menuObserver.disconnect(); + cancelAnimationFrame(frame); + }; + }, [ + compact, + expandSearchOnFocus, + hasCombinedControls, + showCombined, + ]); + + useLayoutEffect(() => { + if (!restoreControlFocus.current) return; + controlsRef.current + ?.querySelector( + showCombined ? "[data-resource-combined-controls] button" : "button", + ) + ?.focus(); + restoreControlFocus.current = false; + }, [showCombined]); + return ( -
-
- - onSearchChange(event.target.value)} - placeholder={searchPlaceholder} - aria-label={searchLabel ?? searchPlaceholder} - className="h-8 pl-8" - /> -
+
+
{ + event.preventDefault(); + if (searchExpanded || (expandSearchOnFocus && isCompactViewport)) + collapseSearch(); + else searchInputRef.current?.focus(); + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) + setSearchExpanded(false); + }} + className={cn( + "flex items-center gap-2", + compact + ? "min-w-0 flex-1 basis-40" + : "w-full min-w-0 sm:w-auto sm:flex-1", + showSearchButton && "min-w-8 max-w-8 grow-0 shrink-0", + )} + > + {showSearchButton ? ( + setSearchExpanded(true)} + /> + ) : ( +
+ + onSearchChange(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchLabel ?? searchPlaceholder} + enterKeyHint={expandSearchOnFocus ? "search" : undefined} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } else if (event.key === "Escape" && searchExpanded) { + event.preventDefault(); + collapseSearch(); + } + }} + className={cn( + "h-8 truncate pl-8 focus:border-ring/60 focus:text-clip focus:ring-2 focus:ring-ring/20 focus-visible:ring-2 focus-visible:ring-ring/20 max-md:pointer-coarse:h-8", + expandSearchOnFocus && "text-xs max-md:pointer-coarse:text-xs", + (searchExpanded || (!searchCondensed && searchValue)) && "pr-8", + )} + /> + {searchExpanded || (!searchCondensed && searchValue) ? ( + + + + + + Clear search + + + ) : null} +
+ )} + {controls ? ( -
{controls}
+
+
+
+ {controls} +
+
+ {showCombined ? ( +
+ {combinedControls} +
+ ) : null} +
) : null} {action ? ( -
+
{action}
) : null} @@ -117,43 +376,83 @@ const RESOURCE_MENU_TRIGGER_ENGAGED_CLASS = const RESOURCE_MENU_TRIGGER_RESTING_CLASS = "border border-input bg-background"; -function ResourceMenuTrigger({ - label, - icon, - active = false, - open = false, - tooltip = label, -}: { - label: string; - icon: IconName; - active?: boolean; - open?: boolean; - tooltip?: ReactNode; -}) { +export const ResourceControlButton = forwardRef< + HTMLButtonElement, + ButtonProps & { + label: string; + icon?: IconName; + active?: boolean; + open?: boolean; + tooltip?: ReactNode; + text?: string; + count?: number; + trailingIcon?: IconName; + } +>(function ResourceControlButton( + { + label, + icon, + active = false, + open = false, + tooltip = label, + text, + count, + trailingIcon, + className, + ...props + }, + ref, +) { return ( - - - + {tooltip} ); +}); + +function ResourceMenuTrigger( + props: React.ComponentProps, +) { + return ( + + + + ); } function nextSelectedValues( @@ -171,6 +470,74 @@ function nextSelectedValues( return [...next]; } +export function ResourceMultiSelectMenuItems({ + label, + selectedValues, + options, + onChange, + compact = false, + clearInFooter = false, + showHeading = true, +}: { + label: string; + selectedValues: readonly string[]; + options: readonly ResourceOption[]; + onChange: (values: string[]) => void; + compact?: boolean; + clearInFooter?: boolean; + showHeading?: boolean; +}) { + const selected = new Set(selectedValues); + function updateValue(option: ResourceOption, checked: boolean) { + const next = nextSelectedValues(option, checked, selectedValues); + if (next !== null) onChange(next); + } + return ( + <> + {showHeading ? ( + + {label} + + ) : null} + {options.map((option) => ( + event.preventDefault()} + onCheckedChange={(checked) => updateValue(option, checked === true)} + > + + + ))} + {clearInFooter ? ( + <> + + { + event.preventDefault(); + onChange([]); + }} + className={cn( + "text-xs text-muted-foreground", + compact && "md:px-1.5 md:py-1", + )} + > + Clear filter + + + ) : null} + + ); +} + export function ResourceMultiSelectMenu({ label, icon, @@ -178,37 +545,42 @@ export function ResourceMultiSelectMenu({ options, onChange, compact = false, + clearInFooter = false, + showLabel = false, + showHeading = true, }: { label: string; - icon: IconName; + icon?: IconName; selectedValues: readonly string[]; options: readonly ResourceOption[]; onChange: (values: string[]) => void; compact?: boolean; + clearInFooter?: boolean; + showLabel?: boolean; + showHeading?: boolean; }) { const [open, setOpen] = useState(false); const selected = new Set(selectedValues); const activeOptions = options.filter((option) => selected.has(option.id)); const activeSelectedCount = activeOptions.length; const selectionSummary = - activeSelectedCount === 0 ? "All" : `${activeSelectedCount} selected`; + activeSelectedCount === 0 + ? "All" + : activeOptions.map((option) => option.label).join(", "); const triggerLabel = activeSelectedCount === 0 ? label : `${label}: ${activeSelectedCount} selected`; const triggerTooltip = `${label}: ${selectionSummary}`; - function updateValue(option: ResourceOption, checked: boolean) { - const next = nextSelectedValues(option, checked, selectedValues); - if (next === null) return; - onChange(next); - } - return ( 0} open={open} tooltip={triggerTooltip} @@ -218,26 +590,15 @@ export function ResourceMultiSelectMenu({ mobileTitle={label} className={cn(compact ? "w-max max-w-64 md:p-0.5" : "min-w-44")} > - - {label} - - {options.map((option) => ( - event.preventDefault()} - onCheckedChange={(checked) => updateValue(option, checked === true)} - > - - - ))} + ); @@ -296,7 +657,6 @@ export function ResourceFilterMenu({ {renderedGroups.map(({ group, selected }, groupIndex) => ( {groupIndex > 0 ? : null} - {} void; placeholderLabel?: string; compact?: boolean; + clearInFooter?: boolean; + showHeading?: boolean; }) { - const [open, setOpen] = useState(false); - const selectedOption = options.find((option) => option.id === value); - const directionLabel = direction === "asc" ? "ascending" : "descending"; - const sortStateLabel = - selectedOption === undefined - ? `Sort: ${placeholderLabel}` - : selectedOption.omitDirection === true - ? `Sort: ${selectedOption.label}` - : `Sort: ${selectedOption.label}, ${directionLabel}`; - return ( - - {} - - + <> + {showHeading ? ( - Sort by + Sort - {onClear === undefined ? null : ( + ) : null} + {onClear === undefined || clearInFooter ? null : ( + { + event.preventDefault(); + onClear(); + }} + className={cn( + "flex items-center justify-between gap-3", + compact && "md:gap-2 md:px-1.5 md:py-1", + )} + > + {placeholderLabel} + + + )} + {options.map((option) => { + const selected = option.id === value; + return ( { event.preventDefault(); - onClear(); + if (option.disabled) return; + onChange(option.id); }} className={cn( "flex items-center justify-between gap-3", compact && "md:gap-2 md:px-1.5 md:py-1", )} > - {placeholderLabel} + - )} - {options.map((option) => { - const selected = option.id === value; - return ( - { - event.preventDefault(); - if (option.disabled) return; - onChange(option.id); - }} - className={cn( - "flex items-center justify-between gap-3", - compact && "md:gap-2 md:px-1.5 md:py-1", - )} - > - - - - ); - })} + ); + })} + {clearInFooter && onClear !== undefined ? ( + <> + + { + event.preventDefault(); + onClear(); + }} + className={cn( + "text-xs text-muted-foreground", + compact && "md:px-1.5 md:py-1", + )} + > + Clear sort + + + ) : null} + + ); +} + +export function ResourceSortMenu({ + value, + direction, + options, + onChange, + onClear, + placeholderLabel = "Sort", + compact = false, + clearInFooter = false, + showLabel = false, + triggerIcon = "ArrowUpDown", + showHeading = true, + tooltip, +}: { + value: string | null; + direction: "asc" | "desc"; + options: readonly ResourceOption[]; + onChange: (value: string) => void; + onClear?: () => void; + placeholderLabel?: string; + compact?: boolean; + clearInFooter?: boolean; + showLabel?: boolean; + triggerIcon?: IconName; + showHeading?: boolean; + tooltip?: string; +}) { + const [open, setOpen] = useState(false); + const selectedOption = options.find((option) => option.id === value); + const directionLabel = direction === "asc" ? "ascending" : "descending"; + const sortStateLabel = + selectedOption === undefined + ? `Sort: ${placeholderLabel}` + : selectedOption.omitDirection === true + ? `Sort: ${selectedOption.label}` + : `Sort: ${selectedOption.label}, ${directionLabel}`; + + return ( + + + + ); @@ -470,34 +900,55 @@ export function ResourceCreateButton({ templateGroups, menuActions = [], onCreate, + compactWhenNarrow = false, }: { label: string; templates: readonly ResourceCreateTemplate[]; templateGroups?: readonly ResourceCreateTemplateGroup[]; menuActions?: readonly ResourceCreateMenuAction[]; onCreate: (prompt?: string) => void; + compactWhenNarrow?: boolean; }) { const groups: readonly ResourceCreateTemplateGroup[] = templateGroups ?? [ { label: "Examples", templates }, ]; + const createButton = ( + + ); return (
- + {compactWhenNarrow ? ( + + + {createButton} + {label} + + + ) : ( + createButton + )}