@raggle-ai/scout-ui
v0.3.0
Published
Reusable React project search components for Raggle Scout
Readme
@raggle-ai/scout-ui
Reusable React project search components for Raggle Scout consumers.
import { ProjectSearchList } from "@raggle-ai/scout-ui";
import "@raggle-ai/scout-ui/styles.css";
<ProjectSearchList
projects={results}
query={query}
onQueryChange={setQuery}
onSelect={setProject}
/>;The caller owns the query and fetches ranked results from its search API. The list
preserves the supplied order and does not filter results again. Set status="loading"
while fetching: stale rows are hidden and cannot be selected. Use status="error",
errorMessage, and optional onRetry for failures. emptyMessage is shown only
when ready with no results; loadingMessage customizes the loading announcement.
ProjectSearchList renders inside an InputFloatingPanel, which floats above the input
that opens it. InputFloatingPanel is exported for custom floating compositions.
Folder filter
useFolderFilter supplies folder choices, selection state, and visible items.
FolderFilterBar renders a Folders button that opens a searchable modal using
ProjectList checkbox rows. Selection changes apply immediately without closing
the modal. Clicking a row or its checkbox toggles that folder; selected folders
combine their items. Clearing every selection shows all folders. Escape or the
close button closes the modal and returns focus to the Folders button.
Import the shared stylesheet. Neither component requires Kennel or Tailwind.
Use mode: "subtree" to include descendants; the default is "exact".
import { FolderFilterBar, useFolderFilter } from "@raggle-ai/scout-ui";
import "@raggle-ai/scout-ui/styles.css";
const filter = useFolderFilter(items, {
rootFolder: folder,
getFolder: (item) => item.folder,
});
<FolderFilterBar
choices={filter.choices}
selected={filter.effectiveSelected}
onSelect={filter.setSelected}
total={filter.total}
/>;
// Render filter.visibleItems in your list.Pure helpers are also available from @raggle-ai/scout-ui/folder-filter without
loading React. The Kennel Tasks plugin uses this filter.
Pass folderHref={(choice) => yourFolderUrl(choice.folder)} to add an external-link
icon at the right of every folder row. It opens the folder in a new tab without
changing the checkbox selection. The caller supplies the navigation URL; Scout UI
does not depend on Kennel routes.
Each choice includes a fixed-size folder glyph. To show folder images, pass
renderIcon={(choice) => <FolderIcon src={icons[choice.folder]} />} and import
FolderIcon from @raggle-ai/scout-ui. The caller supplies image URLs, for example
from useFolderIdentity. Icons retain their dimensions without Scout CSS and
show the folder glyph when an image cannot load.
Workspace groups by default
For server-backed views, discover groups with the Node-only entry point:
import { folderFilterGroups } from "@raggle-ai/scout-ui/folder-filter/server";
const folderGroups = await folderFilterGroups(sourceFolders, viewedFolder);
// Send folderGroups with the list data, then pass it to useFolderFilter.useFolderFilter(items, { rootFolder, getFolder, folderGroups }) combines choices
and counts under the highest folder with a raggle.json below the viewed root.
For example, baker-street/legal and baker-street/registered-office become one
baker-street choice. Selecting it includes both sets of tasks, even in exact
mode. Nested workspace markers do not split a higher-level group.
The viewed root itself does not collapse all choices into one. Folders without a workspace marker below that root keep their owning-folder choice. Outside folders stay unchanged. Discovery checks only source-folder ancestors inside the viewed root, caches shared ancestors for the request, and does not scan the whole repository. Browser exports do not import Node filesystem modules.
Direct list
For pages where results should render inline (no floating panel), use ProjectList
with your own input. Share useProjectNavigation between the input and list to
connect keyboard navigation and screen-reader active-result announcements. Pass
the same projects, status, maxResults, resetKey, and onSelect to the hook and list.
Standalone lists without a controller are a single keyboard-focusable listbox.
The existing handleKeyDown ref remains available for keyboard-only integration.
import { useState } from "react";
import { ProjectList, useProjectNavigation, type ProjectSearchItem } from "@raggle-ai/scout-ui";
import "@raggle-ai/scout-ui/styles.css";
function ProjectSearchPage({ results }: { results: ProjectSearchItem[] }) {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const onSelect = (project: ProjectSearchItem) => setSelectedId(project.id);
const navigation = useProjectNavigation({ projects: results, onSelect, resetKey: query });
return (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search projects"
{...navigation.inputProps}
/>
<ProjectList
navigation={navigation}
ariaLabel="Search results"
projects={results}
selectedProjectId={selectedId}
resetKey={query}
onSelect={onSelect}
/>
</>
);
}Checkbox selection
Supply checkedProjectIds to ProjectList to render native checkbox rows instead
of single-selection options. Handle onSelect(project) by toggling that id in the
controlled array. Clicking the row label or pressing Space on its checkbox calls
onSelect once. Tab moves between checkboxes; the single-selection arrow/Enter
controller is not used in checkbox mode. renderIcon(project) can supply a custom
folder icon. itemHref(project) adds a separate navigation link to checkbox rows;
it is outside the row label, so navigation does not toggle selection. The row
layout and shared styles remain the same in both modes.
Anchored floating search
Pass a trigger ref for automatic above/below placement, viewport collision handling,
Escape/outside-click dismissal, and focus restoration. Anchored panels portal to
document.body; put theme variables on a shared ancestor such as :root.
Without anchorRef, the existing parent-relative positioning is retained.
const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
<button ref={trigger} aria-haspopup="dialog" aria-expanded={open}
onClick={() => setOpen(!open)}>Choose project</button>
<ProjectSearchList
anchorRef={trigger} open={open} onOpenChange={setOpen}
projects={results} query={query} onQueryChange={setQuery}
onSelect={(project) => { setProject(project); setOpen(false); }}
/>Rows adapt to their container width, not just the viewport. Full worktree paths
are available through option accessible names and native hover titles. Existing
--scout-project-* CSS variables also theme standalone lists. className applies
to the results wrapper; header renders outside listbox semantics.
Local demo
Run a standalone Vite demo that imports the package by its published name
(@raggle-ai/scout-ui) and serves representative project data with working
search and selection:
bun devbun dev rebuilds the package (dist/) as needed, starts the web/ Vite dev
server on the stable localhost port 43210, and opens the demo in your default
browser. The demo lives in web/ and depends on this package through a file:..
link, so it exercises the real published export map rather than relative ../src
imports.
The component-state gallery uses local fixtures independently of the Scout feed. Switch between ready/long-name, loading, error/retry, and empty states; resize the inline example or open the anchored floating search.
Publishing
The package publishes from .github/workflows/publish.yml on every push to main and from manual workflow dispatch.
The workflow validates the package on GitHub-hosted Actions, checks the tarball with npm pack --dry-run, and publishes only when the current package version does not already exist on npm.
Configure npm trusted publishing for package @raggle-ai/scout-ui with GitHub organization raggle-ai, repository scout-ui, and workflow filename publish.yml.
npm trusted publishing supports GitHub-hosted runners. The publish job uses ubuntu-latest instead of Blacksmith so npm can verify the OIDC token.
The repository is private, so the publish command does not request npm provenance.
