feat(components): add new FilePicker component [R8S-1050] (#2754)

This commit is contained in:
nickl-portainer
2026-06-10 10:34:14 +12:00
committed by GitHub
parent 154c19403a
commit 6a465637d4
17 changed files with 1485 additions and 3 deletions
+10 -3
View File
@@ -7,8 +7,12 @@ import { Icon } from './Icon';
export function CollapseExpandButton({
onClick,
isExpanded,
quarterRotation = false,
...props
}: { isExpanded: boolean } & ComponentProps<'button'>) {
}: {
isExpanded: boolean;
quarterRotation?: boolean;
} & ComponentProps<'button'>) {
return (
<button
onClick={(e) => {
@@ -31,8 +35,11 @@ export function CollapseExpandButton({
icon={ChevronDown}
size="md"
className={clsx('transition ease-in-out', {
'rotate-180': isExpanded,
'rotate-0': !isExpanded,
'rotate-180': isExpanded && !quarterRotation,
'-rotate-90': !isExpanded && quarterRotation,
'rotate-0':
(!isExpanded && !quarterRotation) ||
(isExpanded && quarterRotation),
})}
/>
</div>
@@ -0,0 +1,121 @@
import { Meta } from '@storybook/react-webpack5';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { CommandPalette } from './CommandPalette';
export default {
component: CommandPalette,
title: 'Components/CommandPalette',
} as Meta;
const samplePaths = [
'src/index.ts',
'src/app.ts',
'src/components/Button.tsx',
'src/components/Input.tsx',
'src/utils/string.ts',
'src/utils/number.ts',
'docker-compose.yml',
'docker-compose.dev.yml',
'.github/workflows/ci.yml',
'package.json',
'tsconfig.json',
'README.md',
];
export function Default() {
const [value, setValue] = useState('');
return (
<div className="relative w-[480px] rounded-md border border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7">
<div className="flex items-center px-3 py-2">
<CommandPalette
value={value}
onChange={setValue}
onCompletion={() => {}}
allFilePaths={samplePaths}
/>
</div>
</div>
);
}
export function DropdownOnType() {
const [value, setValue] = useState('');
return (
<div className="relative w-[480px] rounded-md border border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7">
<div className="flex items-center px-3 py-2">
<CommandPalette
value={value}
onChange={setValue}
onCompletion={() => {}}
allFilePaths={samplePaths}
openDropdownOnFocus={false}
/>
</div>
</div>
);
}
export function WithPortal() {
const [value, setValue] = useState('');
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const portalTargetRef = useRef<HTMLDivElement>(null);
return (
<div
className="w-[480px] overflow-hidden rounded-lg border border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7"
onFocusCapture={() => setIsDropdownVisible(true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDropdownVisible(false);
}
}}
>
<div className="flex items-center border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
<CommandPalette
value={value}
onChange={setValue}
onCompletion={() => {}}
allFilePaths={samplePaths}
renderDropdown={(paths) => {
if (!portalTargetRef.current) return null;
return createPortal(
paths.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
No matching files
</p>
) : (
<>
{paths.map((path) => (
<div
key={path}
className="flex h-8 items-center px-3 hover:bg-gray-3 th-highcontrast:hover:bg-gray-iron-10 th-dark:hover:bg-gray-iron-10"
>
<span className="truncate font-mono text-[13px] text-gray-11 th-highcontrast:text-white th-dark:text-white">
/{path}
</span>
</div>
))}
</>
),
portalTargetRef.current
);
}}
/>
</div>
<div
ref={portalTargetRef}
className="min-h-[120px] overflow-y-auto bg-white th-highcontrast:bg-black th-dark:bg-gray-iron-10"
>
{!isDropdownVisible && (
<p className="px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
Portal target type above to filter files; matches render here
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,146 @@
import { type ReactNode } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CommandPalette } from './CommandPalette';
const allFilePaths = ['foo.yml', 'bar.yml', 'baz.ts'];
function renderSearchBar({
value = '',
onChange = vi.fn(),
addExpression = vi.fn(),
filePaths = allFilePaths,
renderDropdown,
}: {
value?: string;
onChange?: (value: string) => void;
addExpression?: (pattern: string) => void;
filePaths?: string[];
renderDropdown?: (paths: string[]) => ReactNode;
} = {}) {
const user = userEvent.setup();
render(
<CommandPalette
value={value}
onChange={onChange}
onCompletion={addExpression}
allFilePaths={filePaths}
renderDropdown={renderDropdown}
/>
);
return { user, onChange, addExpression };
}
describe('CommandPalette', () => {
it('shows a live match count when the filter is active', async () => {
// *.yml matches foo.yml and bar.yml → 2 matches
const { user } = renderSearchBar({ value: '*.yml' });
const inputElement = screen.getByTestId('command-palette-search-input');
await user.click(inputElement);
expect(screen.getByText(/2 matches/)).toBeVisible();
});
it('shows singular "match" for exactly one result', async () => {
// *.ts matches only baz.ts → 1 match
const { user } = renderSearchBar({ value: '*.ts' });
const inputElement = screen.getByTestId('command-palette-search-input');
await user.click(inputElement);
expect(screen.getByText(/1 match/)).toBeVisible();
});
it('calls addExpression with the glob pattern unchanged when Enter is pressed', async () => {
const addExpression = vi.fn();
const onChange = vi.fn();
const { user } = renderSearchBar({
value: '*.yml',
addExpression,
onChange,
});
const inputElement = screen.getByTestId('command-palette-search-input');
await user.click(inputElement);
await user.keyboard('{Enter}');
expect(addExpression).toHaveBeenCalledWith('*.yml');
expect(onChange).toHaveBeenCalledWith('');
});
it('wraps plain text in wildcards when added as an expression', async () => {
const addExpression = vi.fn();
const { user } = renderSearchBar({ value: 'foo', addExpression });
await user.click(screen.getByRole('textbox'));
await user.keyboard('{Enter}');
expect(addExpression).toHaveBeenCalledWith('*foo*');
});
it('does not call addExpression when Enter is pressed with an empty filter', async () => {
const addExpression = vi.fn();
const { user } = renderSearchBar({ value: '', addExpression });
await user.click(screen.getByRole('textbox'));
await user.keyboard('{Enter}');
expect(addExpression).not.toHaveBeenCalled();
});
it('clears the filter when Escape is pressed', async () => {
const onChange = vi.fn();
const { user } = renderSearchBar({ value: 'some text', onChange });
await user.click(screen.getByRole('textbox'));
await user.keyboard('{Escape}');
expect(onChange).toHaveBeenCalledWith('');
});
it('clears the filter when the clear (X) button is clicked', async () => {
const onChange = vi.fn();
const { user } = renderSearchBar({ value: 'test', onChange });
await user.click(screen.getByRole('button', { name: /clear search/i }));
expect(onChange).toHaveBeenCalledWith('');
});
it('calls addExpression when the Add expression button is clicked', async () => {
const addExpression = vi.fn();
const onChange = vi.fn();
const { user } = renderSearchBar({
value: 'src/**',
addExpression,
onChange,
});
await user.click(screen.getByRole('button', { name: /add expression/i }));
expect(addExpression).toHaveBeenCalledWith('src/**');
expect(onChange).toHaveBeenCalledWith('');
});
it('calls onChange when typing in the input', async () => {
const onChange = vi.fn();
const { user } = renderSearchBar({ onChange });
await user.type(screen.getByRole('textbox'), 'a');
expect(onChange).toHaveBeenCalled();
});
it('shows matching file paths in the dropdown when the filter is active', async () => {
const { user } = renderSearchBar({ value: '*.yml' });
const inputElement = screen.getByTestId('command-palette-search-input');
await user.click(inputElement);
expect(screen.getByText('/foo.yml')).toBeVisible();
expect(screen.getByText('/bar.yml')).toBeVisible();
expect(screen.queryByText('/baz.ts')).not.toBeInTheDocument();
});
it('does not show the dropdown when the filter is empty', () => {
renderSearchBar({ value: '' });
expect(screen.queryByText('/foo.yml')).not.toBeInTheDocument();
});
it('calls renderDropdown with matching paths instead of rendering the default dropdown', async () => {
const renderDropdown = vi.fn(() => null);
const { user } = renderSearchBar({ value: '*.yml', renderDropdown });
const inputElement = screen.getByTestId('command-palette-search-input');
await user.click(inputElement);
expect(renderDropdown).toHaveBeenCalledWith(['foo.yml', 'bar.yml']);
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
it('does not call renderDropdown when the filter is empty', () => {
const renderDropdown = vi.fn(() => null);
renderSearchBar({ value: '', renderDropdown });
expect(renderDropdown).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,144 @@
import { Search, X } from 'lucide-react';
import { type ReactNode, forwardRef, useMemo, useRef, useState } from 'react';
import { pluralize } from '@/react/common/string-utils';
import { Button } from '@@/buttons';
import { filterToPattern } from '../form-components/FilePicker/utils';
import { globToRegex } from './utils';
interface Props {
value: string;
onChange: (value: string) => void;
onCompletion: (pattern: string) => void;
allFilePaths: string[];
renderDropdown?: (paths: string[]) => ReactNode;
openDropdownOnFocus?: boolean;
}
export const CommandPalette = forwardRef<HTMLInputElement, Props>(
function CommandPalette(
{
value,
onChange,
onCompletion,
allFilePaths,
renderDropdown,
openDropdownOnFocus = true,
},
forwardedRef
) {
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
function setRefs(node: HTMLInputElement | null) {
inputRef.current = node;
if (typeof forwardedRef === 'function') {
forwardedRef(node);
} else if (forwardedRef) {
// eslint-disable-next-line no-param-reassign
forwardedRef.current = node;
}
}
const filterTrimmed = value.trim();
const filterActive = filterTrimmed.length > 0;
const matchingPaths = useMemo(() => {
if (openDropdownOnFocus && !isFocused) return [];
if (!filterActive) return allFilePaths;
const re = globToRegex(filterToPattern(filterTrimmed));
return allFilePaths.filter((p) => re.test(p));
}, [
openDropdownOnFocus,
isFocused,
filterActive,
allFilePaths,
filterTrimmed,
]);
return (
<div className="relative z-10 flex min-h-[30px] flex-1 items-center gap-2">
<Search
size={14}
className="shrink-0 text-gray-7 th-highcontrast:text-white th-dark:text-gray-5"
/>
<input
ref={setRefs}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filterActive) handleAddExpression();
if (e.key === 'Escape') {
setIsFocused(false);
inputRef.current?.blur();
onChange('');
}
}}
placeholder="Filter or add expression, e.g. *.yml, src/**/*.ts"
className="flex-1 border-0 bg-transparent text-sm text-gray-11 outline-none placeholder:text-gray-6 th-highcontrast:text-white th-dark:text-white"
data-cy="command-palette-search-input"
/>
{filterActive && (
<>
<Button
color="primary"
onClick={() => handleAddExpression()}
className="shrink-0 border-0 bg-transparent text-gray-7 hover:text-gray-11 th-highcontrast:text-white th-dark:text-gray-5"
aria-label="Add expression"
data-cy="command-palette-search-add-expression"
>
Add expression
</Button>
<span className="shrink-0 text-xs text-gray-7 th-highcontrast:text-gray-5 th-dark:text-gray-5">
{matchingPaths.length}{' '}
{pluralize(matchingPaths.length, 'match', 'matches')}
</span>
</>
)}
{value && (
<button
type="button"
onClick={() => onChange('')}
className="shrink-0 border-0 bg-transparent text-gray-7 hover:text-gray-11 th-highcontrast:text-white th-dark:text-gray-5"
aria-label="Clear search"
data-cy="command-palette-search-clear-expression"
>
<X size={12} />
</button>
)}
{(isFocused || !openDropdownOnFocus) &&
(openDropdownOnFocus || filterActive) &&
(renderDropdown
? renderDropdown(matchingPaths)
: matchingPaths.length > 0 && (
<ul
style={{ top: 'calc(100% + 8px)' }}
className="absolute -left-2 -right-2 z-10 max-h-48 overflow-y-auto rounded-b-md border border-solid border-gray-4 bg-white px-0 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-gray-7 th-dark:bg-gray-iron-10"
>
{matchingPaths.map((path) => (
<li
key={path}
className="list-none px-3 py-1.5 font-mono text-xs text-gray-11 th-highcontrast:text-white th-dark:text-white"
>
/{path}
</li>
))}
</ul>
))}
</div>
);
function handleAddExpression() {
onCompletion(filterToPattern(filterTrimmed));
setIsFocused(false);
inputRef.current?.blur();
onChange('');
}
}
);
@@ -0,0 +1,53 @@
import { globToRegex } from './utils';
describe('globToRegex', () => {
it('matches a file by extension using *', () => {
const re = globToRegex('*.yml');
expect(re.test('foo.yml')).toBe(true);
expect(re.test('foo.ts')).toBe(false);
});
it('wraps pattern in "*" when not explicitly used', () => {
const re = globToRegex('yml');
expect(re.test('foo.yml')).toBe(true);
expect(re.test('foo.ts')).toBe(false);
});
it('matches nested paths with **', () => {
const re = globToRegex('src/**');
expect(re.test('src/foo.ts')).toBe(true);
expect(re.test('src/nested/bar.ts')).toBe(true);
expect(re.test('lib/foo.ts')).toBe(false);
});
it('is case-insensitive', () => {
const re = globToRegex('*.YML');
expect(re.test('foo.yml')).toBe(true);
expect(re.test('FOO.YML')).toBe(true);
});
it('escapes dots so they match only literal dots', () => {
const re = globToRegex('file.ts');
expect(re.test('file.ts')).toBe(true);
expect(re.test('fileXts')).toBe(false);
});
it('matches exact paths with no wildcards', () => {
const re = globToRegex('src/foo.ts');
expect(re.test('src/foo.ts')).toBe(true);
expect(re.test('src/bar.ts')).toBe(false);
});
it('matches any path with a bare wildcard', () => {
const re = globToRegex('*');
expect(re.test('anything')).toBe(true);
});
it('matches exactly one character with ?', () => {
const re = globToRegex('te?t');
expect(re.test('testy')).toBe(true);
expect(re.test('text')).toBe(true);
expect(re.test('tesst')).toBe(false);
expect(re.test('tet')).toBe(false);
});
});
@@ -0,0 +1,18 @@
/**
* Converts a glob pattern to a case-insensitive RegExp.
*
* Patterns without `*` are wrapped in `*...*` for substring matching
* `*` matches any sequence of characters
* `?` matches exactly one character
*/
export function globToRegex(pattern: string): RegExp {
let glob = pattern;
if (!pattern.includes('*')) {
glob = `*${pattern}*`;
}
const escaped = glob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*+/g, '.*')
.replace(/\?/g, '.');
return new RegExp(`^${escaped}$`, 'i');
}
@@ -0,0 +1,219 @@
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { useEffect, useState } from 'react';
import { Button } from '@@/buttons/Button';
import { FileNode } from '@@/form-components/FilePicker/types';
import { FilePicker } from './FilePicker';
import { FilePickerSkeleton } from './FilePickerSkeleton';
const meta: Meta<typeof FilePicker> = {
title: 'Components/Forms/File Picker',
component: FilePicker,
tags: [],
decorators: [
(Story, { viewMode }) => (
<div
style={{
background: 'var(--bg, #f9fafb)',
padding: 'var(--space-16)',
minHeight: viewMode === 'story' ? '100vh' : undefined,
fontFamily: "'DM Sans', system-ui, sans-serif",
}}
>
<Story />
</div>
),
],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'`FilePicker` lets users select individual files or build wildcard expressions (e.g. `*.yml`, `src/**/*.ts`) from a repository tree. Selected items surface as **File** or **Expression** chips. A blue dot marks every tree row that is selected or matched by an expression. Typing in the filter bar shows a live preview of matching files and offers a one-click "Add expression (N matches)" button.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof FilePicker>;
// ─── Demo data ────────────────────────────────────────────────────────────────
const REPO_NODES: FileNode[] = [
{
name: '.github',
children: [
{
name: 'workflows',
children: [
{ name: 'ci.yml' },
{ name: 'deploy.yml' },
{ name: 'release.yml' },
],
},
],
},
{
name: 'config',
children: [
{ name: 'app.yml' },
{ name: 'database.yml' },
{ name: 'cache.yml' },
{ name: 'logging.yml' },
],
},
{
name: 'deploy',
children: [
{ name: 'k8s.yaml' },
{ name: 'helm-values.yaml' },
{ name: 'terraform.tfvars' },
],
},
{
name: 'docs',
children: [
{ name: 'README.md' },
{ name: 'architecture.md' },
{ name: 'CONTRIBUTING.md' },
],
},
{
name: 'scripts',
children: [
{ name: 'build.sh' },
{ name: 'deploy.sh' },
{ name: 'seed.sh' },
],
},
{
name: 'src',
children: [
{
name: 'api',
children: [
{ name: 'routes.ts' },
{ name: 'handlers.ts' },
{ name: 'middleware.ts' },
],
},
{
name: 'components',
children: [
{ name: 'Button.tsx' },
{ name: 'Modal.tsx' },
{ name: 'Table.tsx' },
],
},
{
name: 'utils',
children: [{ name: 'helpers.ts' }, { name: 'types.ts' }],
},
],
},
{
name: 'tests',
children: [
{ name: 'setup.ts' },
{ name: 'integration.ts' },
{ name: 'e2e.ts' },
],
},
{ name: '.gitignore' },
{ name: 'docker-compose.yml' },
{ name: 'docker-compose.prod.yml' },
{ name: 'docker-compose.staging.yml' },
{ name: 'Dockerfile' },
{ name: 'package.json' },
{ name: 'tsconfig.json' },
];
// ─── Skeleton ─────────────────────────────────────────────────────────────────
export const Skeleton: StoryObj<{ loading: boolean }> = {
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loading, setLoading] = useState(true);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [value, setValue] = useState<string[]>([]);
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const id = setTimeout(() => setLoading(false), 2000);
return () => clearTimeout(id);
}, []);
return (
<div className="flex flex-col gap-2 p-2">
{loading ? (
<FilePickerSkeleton />
) : (
<FilePicker
files={REPO_NODES}
value={value}
onChange={setValue}
exampleExpressions={[
'*.yml',
'*.yaml',
'*.ts',
'src/**',
'deploy/**',
]}
/>
)}
</div>
);
},
parameters: {
docs: {
description: {
story:
'Shows a skeleton placeholder while file tree data is loading, then transitions to the real `FilePicker` after 2 seconds. Reload the story to replay the transition.',
},
},
},
};
// ─── Default ──────────────────────────────────────────────────────────────────
export const Default: Story = {
args: {
files: REPO_NODES,
exampleExpressions: ['*.yml', '*.yaml', '*.ts', 'src/**', 'deploy/**'],
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [value, setValue] = useState<string[]>([]);
return (
<div className="flex flex-col gap-2 p-2">
<FilePicker {...args} value={value} onChange={setValue} />
<Button
color="primary"
size="small"
data-cy="file-picker-add-paths"
onClick={() => alert(`Files:\n${value.join('\n') || '(none)'}`)}
>
Add Paths
</Button>
</div>
);
},
parameters: {
docs: {
description: {
story:
'Default state — header shows total file count, suggested pattern chips for one-click expression adding, and an empty tree with all folders collapsed. Click a pattern chip (e.g. `*.yml`) to add it as an expression immediately.',
},
source: {
code: `<FilePicker
files={REPO_NODES}
value={value}
onChange={setValue}
exampleExpressions={['*.yml', '*.yaml', '*.ts', 'src/**', 'deploy/**']}
/>`,
},
},
},
};
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilePicker } from './FilePicker';
import { FileNode } from './types';
const flatFiles: FileNode[] = [
{ name: 'foo.yml' },
{ name: 'bar.ts' },
{ name: 'baz.json' },
];
function renderFilePicker({
files = flatFiles,
initialValue = [],
examplePatterns = ['*.yml', 'src/**'],
}: {
files?: FileNode[];
initialValue?: string[];
examplePatterns?: string[];
} = {}) {
const user = userEvent.setup();
const onChange = vi.fn();
function Wrapper() {
const [value, setValue] = useState<string[]>(initialValue);
return (
<FilePicker
files={files}
value={value}
onChange={(paths) => {
setValue(paths);
onChange(paths);
}}
exampleExpressions={examplePatterns}
/>
);
}
render(<Wrapper />);
return { user, onChange };
}
describe('FilePicker', () => {
it('calls onChange with slash-prefixed sorted paths when files are selected', async () => {
const { user, onChange } = renderFilePicker({
files: [{ name: 'foo.yml' }, { name: 'bar.ts' }],
});
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]); // foo.yml
await user.click(checkboxes[1]); // bar.ts
expect(onChange).toHaveBeenLastCalledWith(['/bar.ts', '/foo.yml']);
});
it('sets the search filter when an example pattern button is clicked', async () => {
const { user } = renderFilePicker({ examplePatterns: ['*.yml'] });
await user.click(screen.getByRole('button', { name: '*.yml' }));
expect(screen.getByRole('textbox')).toHaveValue('*.yml');
});
it('counts expression-matched files as selected', async () => {
const { user } = renderFilePicker({
files: [{ name: 'foo.yml' }, { name: 'bar.ts' }],
});
const filterInput = screen.getByRole('textbox');
await user.click(filterInput);
await user.type(filterInput, '*.yml');
await user.keyboard('{Enter}');
// *.yml matches foo.yml only → 1 selected
expect(screen.getByText('1 selected')).toBeVisible();
});
it('selecting a directory checkbox selects all its descendant files', async () => {
const { user, onChange } = renderFilePicker({
files: [{ name: 'src', children: [{ name: 'a.ts' }, { name: 'b.ts' }] }],
});
// Only the directory checkbox is visible (not expanded)
await user.click(screen.getByRole('checkbox'));
expect(onChange).toHaveBeenCalledWith(['/src/a.ts', '/src/b.ts']);
});
it('deselects all directory files when a fully-checked directory is clicked again', async () => {
const { user, onChange } = renderFilePicker({
files: [{ name: 'src', children: [{ name: 'a.ts' }] }],
});
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox); // select all
await user.click(checkbox); // deselect all
expect(onChange).toHaveBeenLastCalledWith([]);
});
it('clears all selected files when Clear is clicked', async () => {
const { user } = renderFilePicker({
files: [{ name: 'foo.yml' }],
});
const filterInput = screen.getByRole('textbox');
await user.click(filterInput);
await user.type(filterInput, '*.yml');
await user.keyboard('{Enter}');
expect(screen.getByText('1 selected')).toBeVisible();
await user.click(screen.getByRole('button', { name: /clear/i }));
expect(screen.getByText('0 selected')).toBeVisible();
});
});
@@ -0,0 +1,206 @@
import { useMemo, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { pluralize } from '@/react/common/string-utils';
import { Button } from '@@/buttons/Button';
import { Directory, File, FileNode } from '@@/form-components/FilePicker/types';
import { CommandPalette } from '@@/CommandPalette/CommandPalette';
import { globToRegex } from '@@/CommandPalette/utils';
import { Widget } from '@@/Widget';
import { isDirectory, getAllFilePaths, getFolderState } from './utils';
import { TreeNode } from './TreeNode';
import { SelectedPanel } from './SelectedPanel';
interface Props {
files: FileNode[];
/* array of file paths that have been selected */
value: string[];
onChange: (filePaths: string[]) => void;
exampleExpressions?: string[];
}
export function FilePicker({
files,
value,
onChange,
exampleExpressions = ['*.yml', 'src/**', '**/dist/*'],
}: Props) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set([]));
const selected = useMemo(
() => new Set(value.map((p) => (p.startsWith('/') ? p.slice(1) : p))),
[value]
);
const [filter, setFilter] = useState('');
const treeAreaRef = useRef<HTMLDivElement>(null);
const commandPaletteRef = useRef<HTMLInputElement>(null);
const allFilePaths = useMemo(
() => files.flatMap((file) => getAllFilePaths(file, file.name)),
[files]
);
return (
<Widget>
<div className="flex flex-col border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
<p>
Browse files, select individually, or use wildcard expressions.
Contains {allFilePaths.length}{' '}
{pluralize(allFilePaths.length, 'file')}. Try patterns like:
</p>
<div className="flex gap-1">
{exampleExpressions.map((pattern, index) => {
return (
<Button
key={`example-${index}`}
color="none"
className="!th-highcontrast:bg-gray-9 !th-highcontrast:text-gray-3 !th-dark:bg-gray-9 !th-dark:text-gray-3 !bg-gray-3 !p-2 font-mono !text-gray-9"
onClick={() => {
setFilter(pattern);
commandPaletteRef.current?.focus();
}}
data-cy={`example-${index}-button`}
>
{pattern}
</Button>
);
})}
</div>
</div>
<div className="flex items-center gap-2 border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
<CommandPalette
ref={commandPaletteRef}
value={filter}
onChange={setFilter}
onCompletion={addExpression}
allFilePaths={allFilePaths}
openDropdownOnFocus={false}
renderDropdown={renderDropdown}
/>
</div>
<div ref={treeAreaRef} className="flex h-80 overflow-hidden">
{allFilePaths.length === 0 && (
<p className="w-full px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
No files available
</p>
)}
{!filter.trim() && (
<>
<div className="min-w-0 flex-1 overflow-y-auto">
{files.map((file) => (
<TreeNode
key={file.name}
item={file}
depth={0}
expanded={expanded}
selected={selected}
nodePath={file.name}
onExpandDirectory={toggleDirectory}
onToggleSelect={toggleSelect}
/>
))}
</div>
<div className="flex w-80 shrink-0 flex-col border-b-0 border-l border-r-0 border-t-0 border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7">
<SelectedPanel selected={selected} removeFile={removeFile} />
</div>
</>
)}
</div>
<div className="flex items-center justify-between border-b-0 border-l-0 border-r-0 border-t border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
<span className="text-xs text-gray-6 th-highcontrast:text-white th-dark:text-gray-5">
{selected.size} selected
</span>
<Button
color="default"
size="small"
onClick={() => onChange([])}
data-cy="file-picker-clear"
>
Clear
</Button>
</div>
</Widget>
);
function renderDropdown(paths: string[]): ReactNode {
if (!treeAreaRef.current) return null;
return createPortal(
paths.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
No matching files
</p>
) : (
<ul className="px-1">
{paths.map((path) => (
<li key={path} className="flex h-8 list-none items-center px-3">
<span className="truncate font-mono text-[13px] text-gray-11 th-highcontrast:text-white th-dark:text-white">
/{path}
</span>
</li>
))}
</ul>
),
treeAreaRef.current
);
}
function handleChange(next: Set<string>) {
onChange(
Array.from(next)
.map((f) => `/${f}`)
.sort()
);
}
function toggleDirectory(path: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}
function toggleSelect(path: string, item: Directory | File) {
if (!isDirectory(item)) {
const next = new Set(selected);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
handleChange(next);
return;
}
const filePaths = getAllFilePaths(item, path);
const state = getFolderState(item, selected, path);
const next = new Set(selected);
if (state === 'checked') {
filePaths.forEach((p) => next.delete(p));
} else {
filePaths.forEach((p) => next.add(p));
}
handleChange(next);
}
function removeFile(path: string) {
const next = new Set(selected);
next.delete(path);
handleChange(next);
}
function addExpression(pattern: string) {
const re = globToRegex(pattern);
const matchedPaths = allFilePaths.filter((p) => re.test(p));
handleChange(new Set([...selected, ...matchedPaths]));
}
}
@@ -0,0 +1,50 @@
export function FilePickerSkeleton() {
return (
<div className="animate-pulse overflow-hidden rounded-lg border border-solid border-gray-4 bg-white th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-gray-7 th-dark:bg-gray-iron-10">
<div className="flex flex-col gap-2 border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
<div className="h-3 w-3/4 rounded bg-gray-3 th-dark:bg-gray-8" />
<div className="flex gap-1">
<div className="h-7 w-14 rounded bg-gray-3 th-dark:bg-gray-8" />
<div className="h-7 w-12 rounded bg-gray-3 th-dark:bg-gray-8" />
<div className="h-7 w-16 rounded bg-gray-3 th-dark:bg-gray-8" />
</div>
</div>
<div className="flex items-center border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
<div className="h-8 w-full rounded bg-gray-3 th-dark:bg-gray-8" />
</div>
<div className="flex h-80 overflow-hidden">
<div className="min-w-0 flex-1 overflow-y-auto px-1 py-1">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex h-8 items-center gap-2 px-3"
style={{ paddingLeft: `${(i % 3) * 12 + 12}px` }}
>
<div className="h-4 w-4 shrink-0 rounded bg-gray-3 th-dark:bg-gray-8" />
<div
className="h-3 rounded bg-gray-3 th-dark:bg-gray-8"
style={{ width: `${40 + ((i * 37) % 80)}px` }}
/>
</div>
))}
</div>
<div className="flex w-80 shrink-0 flex-col border-b-0 border-l border-r-0 border-t-0 border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7">
<div className="border-b border-solid border-gray-4 px-3 py-2 th-dark:border-gray-7">
<div className="h-3 w-20 rounded bg-gray-3 th-dark:bg-gray-8" />
</div>
<div className="flex flex-1 items-center justify-center">
<div className="h-3 w-32 rounded bg-gray-2 th-dark:bg-gray-9" />
</div>
</div>
</div>
<div className="flex items-center justify-between border-b-0 border-l-0 border-r-0 border-t border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
<div className="h-3 w-16 rounded bg-gray-3 th-dark:bg-gray-8" />
<div className="h-7 w-16 rounded bg-gray-3 th-dark:bg-gray-8" />
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SelectedPanel } from './SelectedPanel';
function renderSelectedPanel({
selected = new Set<string>(),
removeFile = vi.fn(),
}: {
selected?: Set<string>;
removeFile?: (path: string) => void;
} = {}) {
const user = userEvent.setup();
render(<SelectedPanel selected={selected} removeFile={removeFile} />);
return { user, removeFile };
}
describe('SelectedPanel', () => {
it('calls removeFile with the correct path when the remove button is clicked', async () => {
const removeFile = vi.fn();
const { user } = renderSelectedPanel({
selected: new Set(['src/foo.ts']),
removeFile,
});
await user.click(
screen.getByRole('button', { name: /remove file \/src\/foo\.ts/i })
);
expect(removeFile).toHaveBeenCalledWith('src/foo.ts');
});
it('shows "No files selected" when nothing is selected', () => {
renderSelectedPanel();
expect(screen.getByText(/no files selected/i)).toBeVisible();
});
it('renders all selected file paths', () => {
renderSelectedPanel({
selected: new Set(['lib/index.ts', 'src/foo.ts']),
});
expect(screen.getByTitle('/lib/index.ts')).toBeVisible();
expect(screen.getByTitle('/src/foo.ts')).toBeVisible();
});
});
@@ -0,0 +1,62 @@
import { X } from 'lucide-react';
import { pluralize } from '@/react/common/string-utils';
import { Badge } from '@@/Badge';
interface Props {
selected: Set<string>;
removeFile: (path: string) => void;
}
export function SelectedPanel({ selected, removeFile }: Props) {
const selectedPaths = Array.from(selected).sort();
return (
<>
<div className="shrink-0 border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2.5 text-sm font-semibold text-gray-11 th-highcontrast:border-white th-highcontrast:text-white th-dark:border-gray-7 th-dark:text-white">
{selected.size} Selected {pluralize(selected.size, 'File')}
</div>
<div className="flex-1 overflow-y-auto">
{selectedPaths.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
No files selected
</p>
) : (
selectedPaths.map((path) => (
<FileRow key={path} path={path} onRemove={() => removeFile(path)} />
))
)}
</div>
</>
);
}
interface FileRowProps {
path: string;
onRemove: () => void;
}
function FileRow({ path, onRemove }: FileRowProps) {
return (
<div className="flex items-center gap-2 border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 bg-blue-1 px-3 py-2.5 th-highcontrast:border-white th-highcontrast:bg-gray-iron-10 th-dark:border-gray-7 th-dark:bg-blue-11">
<Badge type="infoSecondary" shape="rect" size="sm" className="uppercase">
File
</Badge>
<span
className="min-w-0 flex-1 truncate font-mono text-xs text-gray-11 th-highcontrast:text-white th-dark:text-white"
title={`/${path}`}
>
/{path}
</span>
<button
type="button"
onClick={onRemove}
className="flex shrink-0 items-center justify-center rounded border-0 bg-transparent p-0.5 text-gray-6 hover:text-gray-9 th-highcontrast:text-white th-dark:text-gray-6"
aria-label={`Remove file /${path}`}
>
<X size={12} />
</button>
</div>
);
}
@@ -0,0 +1,115 @@
import { useRef } from 'react';
import { slugify } from 'markdown-to-jsx';
import { CollapseExpandButton } from '@@/CollapseExpandButton';
import { Checkbox } from '@@/form-components/Checkbox';
import { isDirectory, getFolderState, isDirectoryWithChildren } from './utils';
import { TreeNodeIcon } from './TreeNodeIcon';
import { FileNode } from './types';
const INDENT_PX = 16;
const DOUBLE_CLICK_DELAY_MS = 250;
interface Props {
item: FileNode;
depth: number;
expanded: Set<string>;
selected: Set<string>;
nodePath: string;
onExpandDirectory: (path: string) => void;
onToggleSelect: (path: string, item: FileNode) => void;
}
export function TreeNode({
item,
depth,
expanded,
selected,
nodePath,
onExpandDirectory,
onToggleSelect,
}: Props) {
const isDir = isDirectory(item);
const hasSubDir = isDirectoryWithChildren(item);
const isOpen = isDir && expanded.has(nodePath);
const folderState = isDir ? getFolderState(item, selected, nodePath) : null;
const isChecked = isDir ? folderState === 'checked' : selected.has(nodePath);
const isIndeterminate = folderState === 'indeterminate';
const checkboxId = slugify(`${nodePath}/${item.name}`);
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleRowClick() {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
if (isDir) {
onExpandDirectory(nodePath);
}
return;
}
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
onToggleSelect(nodePath, item);
}, DOUBLE_CLICK_DELAY_MS);
}
return (
<>
<div
role="button"
tabIndex={0}
className="relative flex h-8 cursor-pointer select-none items-center gap-1 pr-2"
style={{ paddingLeft: depth * INDENT_PX + 12 }}
onClick={handleRowClick}
onKeyDown={(e) => {
if (e.key === 'Enter' && isDir) onExpandDirectory(nodePath);
if (e.key === 'Space') onToggleSelect(nodePath, item);
}}
>
{isDir ? (
<CollapseExpandButton
quarterRotation
isExpanded={isOpen}
onClick={() => onExpandDirectory(nodePath)}
/>
) : (
<span className="w-[22px] shrink-0" />
)}
<Checkbox
id={checkboxId}
data-cy={checkboxId}
checked={isChecked}
indeterminate={isIndeterminate}
onChange={() => onToggleSelect(nodePath, item)}
onClick={(e) => e.stopPropagation()}
aria-labelledby={`${checkboxId}-label`}
/>
<TreeNodeIcon node={item} />
<span
id={`${checkboxId}-label`}
className="flex-1 truncate text-sm text-gray-11 th-highcontrast:text-white th-dark:text-white"
title={item.name}
>
{item.name}
</span>
</div>
{isOpen &&
hasSubDir &&
item.children.map((child, index) => (
<TreeNode
key={`${nodePath}/${child.name}/${index}`}
item={child}
depth={depth + 1}
expanded={expanded}
selected={selected}
nodePath={`${nodePath}/${child.name}`}
onExpandDirectory={onExpandDirectory}
onToggleSelect={onToggleSelect}
/>
))}
</>
);
}
@@ -0,0 +1,23 @@
import { File as FileIcon, Folder } from 'lucide-react';
import { isDirectory } from '@@/form-components/FilePicker/utils';
import { Directory, File } from './types';
interface Props {
node: Directory | File;
}
export function TreeNodeIcon({ node }: Props) {
return isDirectory(node) ? (
<Folder
size={14}
className="shrink-0 text-blue-7 th-highcontrast:text-white th-dark:text-blue-5"
/>
) : (
<FileIcon
size={14}
className="shrink-0 text-gray-7 th-highcontrast:text-white th-dark:text-gray-5"
/>
);
}
@@ -0,0 +1,10 @@
export type FileNode = Directory | File;
export interface File {
name: string;
}
export interface Directory {
name: string;
children?: FileNode[];
}
@@ -0,0 +1,121 @@
import { Directory, File, FileNode } from './types';
import {
isDirectory,
getAllFilePaths,
getFolderState,
filterToPattern,
} from './utils';
function file(name: string): File {
return { name };
}
function dir(name: string, children: FileNode[] = []): Directory {
return { name, children };
}
describe('isDirectory', () => {
it('returns true when the item has a children property', () => {
expect(isDirectory(dir('src'))).toBe(true);
});
it('returns true when the children property is present but undefined', () => {
const dirWithUndefinedChildren: Directory = {
name: 'empty',
children: undefined,
};
expect(isDirectory(dirWithUndefinedChildren)).toBe(true);
});
it('returns false for a file without children', () => {
expect(isDirectory(file('foo.ts'))).toBe(false);
});
});
describe('getAllFilePaths', () => {
it('returns the node path for a single file', () => {
expect(getAllFilePaths(file('foo.ts'), 'foo.ts')).toEqual(['foo.ts']);
});
it('returns all child file paths for a flat directory', () => {
const node = dir('src', [file('a.ts'), file('b.ts')]);
expect(getAllFilePaths(node, 'src')).toEqual(['src/a.ts', 'src/b.ts']);
});
it('returns an empty array for an empty directory', () => {
expect(getAllFilePaths(dir('empty'), 'empty')).toEqual([]);
});
it('returns deeply nested file paths', () => {
const node = dir('root', [dir('sub', [file('deep.ts')])]);
expect(getAllFilePaths(node, 'root')).toEqual(['root/sub/deep.ts']);
});
it('returns all paths across multiple nested levels', () => {
const node = dir('a', [
file('top.ts'),
dir('b', [file('nested.ts'), dir('c', [file('deep.ts')])]),
]);
expect(getAllFilePaths(node, 'a').sort()).toEqual([
'a/b/c/deep.ts',
'a/b/nested.ts',
'a/top.ts',
]);
});
});
describe('getFolderState', () => {
const tree = dir('src', [file('a.ts'), file('b.ts')]);
it('returns unchecked when no files are selected', () => {
expect(getFolderState(tree, new Set(), 'src')).toBe('unchecked');
});
it('returns checked when all files in the directory are selected', () => {
expect(getFolderState(tree, new Set(['src/a.ts', 'src/b.ts']), 'src')).toBe(
'checked'
);
});
it('returns indeterminate when some but not all files are selected', () => {
expect(getFolderState(tree, new Set(['src/a.ts']), 'src')).toBe(
'indeterminate'
);
});
it('returns unchecked for an empty directory', () => {
expect(getFolderState(dir('empty'), new Set(), 'empty')).toBe('unchecked');
});
it('ignores selected paths outside the directory', () => {
expect(getFolderState(tree, new Set(['other/file.ts']), 'src')).toBe(
'unchecked'
);
});
});
describe('filterToPattern', () => {
it('wraps plain text with wildcards', () => {
expect(filterToPattern('foo')).toBe('*foo*');
});
it('leaves a glob pattern with * unchanged', () => {
expect(filterToPattern('*.yml')).toBe('*.yml');
});
it('leaves a glob pattern with ? unchanged', () => {
expect(filterToPattern('file?.ts')).toBe('file?.ts');
});
it('trims whitespace before wrapping plain text', () => {
expect(filterToPattern(' foo ')).toBe('*foo*');
});
it('trims whitespace before preserving a glob', () => {
expect(filterToPattern(' *.yml ')).toBe('*.yml');
});
it('handles empty string after trimming', () => {
expect(filterToPattern(' ')).toBe('**');
});
});
@@ -0,0 +1,39 @@
import { Directory, FileNode } from './types';
export function isDirectory(item: FileNode): item is Directory {
return 'children' in item;
}
export function isDirectoryWithChildren(
item: FileNode
): item is Required<Directory> {
return isDirectory(item) && 'children' in item;
}
export function getAllFilePaths(item: FileNode, nodePath: string): string[] {
if (!isDirectory(item)) {
return [nodePath];
}
return (item.children ?? []).flatMap((child) =>
getAllFilePaths(child, `${nodePath}/${child.name}`)
);
}
export function getFolderState(
item: Directory,
selected: Set<string>,
nodePath: string
): 'checked' | 'indeterminate' | 'unchecked' {
const filePaths = getAllFilePaths(item, nodePath);
if (filePaths.length === 0) return 'unchecked';
const selectedCount = filePaths.filter((p) => selected.has(p)).length;
if (selectedCount === filePaths.length) return 'checked';
if (selectedCount > 0) return 'indeterminate';
return 'unchecked';
}
export function filterToPattern(text: string): string {
const t = text.trim();
if (/[*?]/.test(t)) return t;
return `*${t}*`;
}