Skip to content

Commit 7552b53

Browse files
andreea-eneclaude
andcommitted
feat(apollo-wind): add ResourcePicker
A field and popover for choosing one record from a grouped list. The component is generic over what it lists: a group is a heading with rows under it, and a row is an id, a label and an optional icon, so the same control serves Data Fabric entities, queues, storage buckets or anything else with that shape. The domain lives in the data a consumer passes. Rows commit on click, so there is no Cancel and Select pair: one resource is one decision. The footer's two slots hold the ways out of the picker instead, which keeps product routes out of the component. Built on Command and Popover, following VariablePicker, with the file layout FolderPicker uses. Ids must be unique across groups; labels need not be, since the same name can appear under two folders. Selection is carried by a tick and a brand tint, kept apart from the neutral fill that marks the cursor: `surface-selected` and `surface-hover` resolve to the same value in every theme, so a selected fill built on them would be indistinguishable from the row under the pointer. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent ed3935b commit 7552b53

12 files changed

Lines changed: 1966 additions & 0 deletions

‎packages/apollo-wind/src/components/ui/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export * from './prompt-value-control';
4747
export * from './quick-form-field';
4848
export * from './radio-group';
4949
export * from './resizable';
50+
export * from './resource-picker';
5051
export * from './scroll-area';
5152
export * from './search';
5253
export * from './select';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { ChevronRight, Folder } from 'lucide-react';
2+
import type { ReactNode } from 'react';
3+
import { cn } from '@/lib';
4+
5+
export interface ResourceGroupHeaderProps {
6+
label: string;
7+
count: number;
8+
collapsed: boolean;
9+
showCount: boolean;
10+
icon?: ReactNode;
11+
/** Set while a search force-expands every group, so the toggle is inert. */
12+
disabled?: boolean;
13+
onToggle: () => void;
14+
}
15+
16+
/**
17+
* The heading that opens and closes one group of rows.
18+
*
19+
* A plain button rather than a `CommandItem`: this is a disclosure control,
20+
* not something selectable, so cmdk must neither land the keyboard cursor on
21+
* it nor match it against the search. Its chevron sits in the same leading
22+
* column the rows reserve for their tick, so glyphs stack in clean columns.
23+
*/
24+
export function ResourceGroupHeader({
25+
label,
26+
count,
27+
collapsed,
28+
showCount,
29+
icon,
30+
disabled = false,
31+
onToggle,
32+
}: ResourceGroupHeaderProps) {
33+
return (
34+
<button
35+
type="button"
36+
aria-expanded={!collapsed}
37+
disabled={disabled}
38+
className="disabled:cursor-default disabled:hover:bg-transparent flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent py-1.5 pl-3 pr-3 text-left text-sm font-medium text-foreground-muted transition-colors hover:bg-surface-overlay"
39+
onClick={onToggle}
40+
// cmdk commits its active row on an Enter that reaches the list, which
41+
// would close the picker instead of toggling the group. Stopping the
42+
// bubble leaves the button's own activation, a default action, intact.
43+
onKeyDown={(event) => {
44+
if (event.key === 'Enter') event.stopPropagation();
45+
}}
46+
>
47+
<span className="grid size-3.5 shrink-0 place-items-center">
48+
<ChevronRight
49+
size={13}
50+
className={cn('text-foreground-subtle transition-transform', !collapsed && 'rotate-90')}
51+
/>
52+
</span>
53+
<span className="grid size-3.5 shrink-0 place-items-center text-foreground-muted">
54+
{icon ?? <Folder size={14} />}
55+
</span>
56+
<span className="min-w-0 flex-1 truncate">{label}</span>
57+
{showCount && (
58+
<span className="shrink-0 text-xs font-medium text-foreground-subtle">{count}</span>
59+
)}
60+
</button>
61+
);
62+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
import { ResourcePickerEmptyState } from './resource-picker-empty-state';
4+
5+
describe('ResourcePickerEmptyState', () => {
6+
it('distinguishes an empty list from a failed search', () => {
7+
const { rerender } = render(<ResourcePickerEmptyState />);
8+
expect(screen.getByText('No matches.')).toBeInTheDocument();
9+
10+
rerender(<ResourcePickerEmptyState query="zzz" />);
11+
expect(screen.getByText('No results match “zzz”.')).toBeInTheDocument();
12+
expect(screen.queryByText('No matches.')).not.toBeInTheDocument();
13+
});
14+
15+
it('takes consumer wording for the empty case', () => {
16+
render(<ResourcePickerEmptyState emptyText="No entities yet." />);
17+
expect(screen.getByText('No entities yet.')).toBeInTheDocument();
18+
});
19+
20+
it('keeps the consumer wording out of the no-match message', () => {
21+
render(<ResourcePickerEmptyState query="zzz" emptyText="No entities yet." />);
22+
expect(screen.getByText('No results match “zzz”.')).toBeInTheDocument();
23+
expect(screen.queryByText('No entities yet.')).not.toBeInTheDocument();
24+
});
25+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export interface ResourcePickerEmptyStateProps {
2+
/** The active search text, trimmed. Empty when the list is unfiltered. */
3+
query?: string;
4+
emptyText?: string;
5+
}
6+
7+
/**
8+
* What the list shows when it has no rows.
9+
*
10+
* A picker with nothing in it and a search that matched nothing are not the
11+
* same thing, so they do not collapse into one message: the first is a state
12+
* of the data, the second is a state of the query, and only the second is
13+
* undone by clearing the search.
14+
*/
15+
export function ResourcePickerEmptyState({
16+
query = '',
17+
emptyText = 'No matches.',
18+
}: ResourcePickerEmptyStateProps) {
19+
return (
20+
<div className="px-3 py-2 text-xs text-foreground-muted">
21+
{query ? `No results match “${query}”.` : emptyText}
22+
</div>
23+
);
24+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ReactNode } from 'react';
2+
3+
export interface ResourcePickerFooterProps {
4+
leading?: ReactNode;
5+
trailing?: ReactNode;
6+
}
7+
8+
/**
9+
* The link row that ends the list.
10+
*
11+
* Two slots rather than a Cancel and Select pair: rows commit on click, so
12+
* there is nothing left to confirm, and the space belongs instead to the ways
13+
* out of the picker. Renders nothing when neither slot is filled, so a picker
14+
* with no links does not end in an empty rule.
15+
*/
16+
export function ResourcePickerFooter({ leading, trailing }: ResourcePickerFooterProps) {
17+
if (!leading && !trailing) return null;
18+
19+
return (
20+
// Enter stops here, so a focused footer control runs its own action rather
21+
// than cmdk committing the active row. Native activation still happens.
22+
// biome-ignore lint/a11y/noStaticElementInteractions: only stops propagation; the controls inside are interactive
23+
<div
24+
className="flex shrink-0 items-center justify-between gap-2 border-t border-border bg-popover px-3 py-2"
25+
onKeyDown={(event) => {
26+
if (event.key === 'Enter') event.stopPropagation();
27+
}}
28+
>
29+
<div className="flex min-w-0 items-center gap-1">{leading}</div>
30+
<div className="flex shrink-0 items-center gap-1">{trailing}</div>
31+
</div>
32+
);
33+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Check } from 'lucide-react';
2+
import { CommandItem } from '@/components/ui/command';
3+
import { cn } from '@/lib';
4+
import type { ResourceItem } from '../types';
5+
6+
export interface ResourceRowProps {
7+
item: ResourceItem;
8+
chosen: boolean;
9+
/** Nesting depth. Rows under a group sit one step in. */
10+
depth: number;
11+
onSelect: () => void;
12+
}
13+
14+
/**
15+
* One selectable resource.
16+
*
17+
* The tick has a reserved column of its own rather than trailing the label,
18+
* so it stays in one place as the rows above and below it change, and the
19+
* chosen row carries the rest of the emphasis in its background.
20+
*/
21+
export function ResourceRow({ item, chosen, depth, onSelect }: ResourceRowProps) {
22+
return (
23+
<CommandItem
24+
value={item.id}
25+
// Named explicitly so the decorative glyphs stay out of it, but with the
26+
// description kept in: two rows can share a label and differ only there.
27+
aria-label={item.description ? `${item.label}, ${item.description}` : item.label}
28+
// cmdk owns `aria-selected`, which it moves with the keyboard cursor as
29+
// the active descendant, so the committed row is exposed as checked
30+
// instead. Every row carries it, so each one reads as part of a choice.
31+
aria-checked={chosen}
32+
disabled={item.disabled}
33+
onSelect={onSelect}
34+
// Two fills for two states. The neutral one is cursor position: cmdk
35+
// sets data-selected for both the keyboard cursor and hover, so one rule
36+
// covers each. The chosen row takes the brand tint instead, and keeps it
37+
// under the cursor. `surface-selected` is no use here, since it is the
38+
// same value as `surface-hover` in every theme and the two states would
39+
// be indistinguishable.
40+
className={cn(
41+
'flex cursor-pointer items-center gap-2 rounded-none py-1.5 pr-3 text-sm',
42+
chosen
43+
? 'bg-brand-subtle font-medium data-[selected=true]:bg-brand-subtle'
44+
: 'data-[selected=true]:bg-surface-overlay data-[selected=true]:text-foreground'
45+
)}
46+
// One 16px step per level, matching the picker family's scale.
47+
style={{ paddingLeft: `${12 + depth * 16}px` }}
48+
>
49+
<span className="grid size-3.5 shrink-0 place-items-center">
50+
{chosen && <Check className="size-3.5 text-foreground-accent" strokeWidth={3} />}
51+
</span>
52+
{item.icon && (
53+
<span className="grid size-3.5 shrink-0 place-items-center text-foreground-muted">
54+
{item.icon}
55+
</span>
56+
)}
57+
<span
58+
className={cn(
59+
'min-w-0 flex-1 truncate',
60+
chosen ? 'text-foreground-accent' : 'text-foreground'
61+
)}
62+
>
63+
{item.label}
64+
</span>
65+
{item.description && (
66+
// Shrinks and is capped, so a long description ellipsizes rather than
67+
// pushing the label out of the row.
68+
<span className="min-w-0 max-w-[45%] truncate text-xs text-foreground-subtle">
69+
{item.description}
70+
</span>
71+
)}
72+
</CommandItem>
73+
);
74+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export type {
2+
ResourceGroup,
3+
ResourceItem,
4+
ResourcePickerContentProps,
5+
ResourcePickerProps,
6+
} from './resource-picker';
7+
export {
8+
keepResourceSearchOnEscape,
9+
ResourcePicker,
10+
ResourcePickerContent,
11+
} from './resource-picker';

0 commit comments

Comments
 (0)