Extract ResourceControls, refactor ResourcesScreen layout, and add scrollable accordion panels

- Extract ResourceControls group component from ResourcesScreen:
  title, search, list-changed indicator, and three-section accordion
  (URIs, Templates, Subscriptions) with per-panel ScrollArea.Autosize
- Refactor ResourcesScreen to sidebar+panel Flex layout matching
  HistoryScreen/LoggingScreen pattern (ScreenLayout, Sidebar, SidebarCard)
- Each accordion panel dynamically computes max height based on how many
  sections are open, dividing available space evenly
- Accordion uses controlled state (value/onChange) to track open sections
- Sidebar card sizes to content naturally (compact when collapsed,
  grows with open sections, capped by scroll areas)
- Right panel wrapped in ScrollArea.Autosize for long content
- Add AllSections story with 20 URIs, 7 templates, 8 subscriptions
- Add more templates and subscriptions to ConnectedView ResourcesActive
- Add ResourceControls stories (Default, WithSearch, Empty)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-31 14:38:02 -04:00
commit eca6cf7665
2 changed files with 627 additions and 162 deletions

View file

@ -0,0 +1,72 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ResourceControls } from "./ResourceControls";
const meta: Meta<typeof ResourceControls> = {
title: "Groups/ResourceControls",
component: ResourceControls,
args: {
searchText: "",
onSearchChange: fn(),
onRefreshList: fn(),
onSelectUri: fn(),
onSelectTemplate: fn(),
onUnsubscribeResource: fn(),
listChanged: false,
},
};
export default meta;
type Story = StoryObj<typeof ResourceControls>;
export const Default: Story = {
args: {
resources: [
{
name: "config.json",
uri: "file:///config.json",
annotations: { audience: "developer", priority: 0.8 },
selected: false,
},
{
name: "README.md",
uri: "file:///README.md",
selected: false,
},
{
name: "schema.sql",
uri: "file:///schema.sql",
selected: false,
},
],
templates: [
{
name: "User Profile",
uriTemplate: "file:///users/{userId}/profile",
selected: false,
},
],
subscriptions: [
{
name: "config.json",
uri: "file:///config.json",
lastUpdated: "2026-03-17T10:30:00Z",
},
],
},
};
export const WithSearch: Story = {
args: {
...Default.args,
searchText: "config",
},
};
export const Empty: Story = {
args: {
resources: [],
templates: [],
subscriptions: [],
},
};

View file

@ -0,0 +1,144 @@
import { useState } from "react";
import { Accordion, ScrollArea, Stack, TextInput, Title } from "@mantine/core";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ResourceListItem } from "../ResourceListItem/ResourceListItem";
import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem";
import type {
ResourceItem,
TemplateListItem,
SubscriptionItem,
} from "../../screens/ResourcesScreen/ResourcesScreen";
export interface ResourceControlsProps {
resources: ResourceItem[];
templates: TemplateListItem[];
subscriptions: SubscriptionItem[];
listChanged: boolean;
searchText: string;
onSearchChange: (text: string) => void;
onRefreshList: () => void;
onSelectUri: (uri: string) => void;
onSelectTemplate: (uriTemplate: string) => void;
onUnsubscribeResource: (uri: string) => void;
}
// Available height for each accordion panel's scroll area.
// Subtracts: AppShell extra padding (md*2), screen padding (xl*2, already in calc),
// card padding (2*lg=40), title+search+gaps (~84), 3 accordion controls (~144),
// and per-panel accordion padding (~20px each).
function panelMaxHeight(openCount: number): string {
const n = Math.max(openCount, 1);
const fixedChrome = 300;
const perPanelChrome = 20;
return `calc((100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - ${fixedChrome + perPanelChrome * n}px) / ${n})`;
}
function formatSectionCount(label: string, count: number): string {
return `${label} (${count})`;
}
function templateDisplayName(item: TemplateListItem): string {
return item.title ?? item.name;
}
export function ResourceControls({
resources,
templates,
subscriptions,
listChanged,
searchText,
onSearchChange,
onRefreshList,
onSelectUri,
onSelectTemplate,
onUnsubscribeResource,
}: ResourceControlsProps) {
const filteredResources = resources.filter((r) =>
r.name.toLowerCase().includes(searchText.toLowerCase()),
);
const defaultOpen = [
...(filteredResources.length > 0 ? ["resources"] : []),
...(templates.length > 0 ? ["templates"] : []),
...(subscriptions.length > 0 ? ["subscriptions"] : []),
];
const [openSections, setOpenSections] = useState<string[]>(defaultOpen);
const maxHeight = panelMaxHeight(openSections.length);
return (
<Stack gap="sm">
<Title order={4}>Resources</Title>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
<TextInput
placeholder="Search..."
value={searchText}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<Accordion multiple value={openSections} onChange={setOpenSections}>
<Accordion.Item value="resources">
<Accordion.Control disabled={filteredResources.length === 0}>
{formatSectionCount("URIs", filteredResources.length)}
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize mah={maxHeight}>
<Stack gap="xs">
{filteredResources.map((resource) => (
<ResourceListItem
key={resource.uri}
name={resource.name}
uri={resource.uri}
annotations={resource.annotations}
selected={resource.selected}
onClick={() => onSelectUri(resource.uri)}
/>
))}
</Stack>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="templates">
<Accordion.Control disabled={templates.length === 0}>
{formatSectionCount("Templates", templates.length)}
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize mah={maxHeight}>
<Stack gap="xs">
{templates.map((template) => (
<ResourceListItem
key={template.uriTemplate}
name={templateDisplayName(template)}
uri={template.uriTemplate}
selected={template.selected}
onClick={() => onSelectTemplate(template.uriTemplate)}
/>
))}
</Stack>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="subscriptions">
<Accordion.Control disabled={subscriptions.length === 0}>
{formatSectionCount("Subscriptions", subscriptions.length)}
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize mah={maxHeight}>
<Stack gap="xs">
{subscriptions.map((sub) => (
<ResourceSubscribedItem
key={sub.name}
name={sub.name}
lastUpdated={sub.lastUpdated}
onUnsubscribe={() => onUnsubscribeResource(sub.uri)}
/>
))}
</Stack>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}