Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .vscode/settings.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@
"**/.yarn": true,
"**/.pnp.*": true
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
9 changes: 8 additions & 1 deletion src/components/filters/FilterDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ import {
StyledFooter,
StyledLeftPanel,
} from './FilterDropdownStyles';
import { FilterDropdownCategory, FilterDropdownProps } from './FilterDropdownTypes';
import {
FilterDropdownCategory,
FilterDropdownProps,
isCategoryWithContent,
} from './FilterDropdownTypes';
import { useFilterDropdownKeyboardThrottle } from './useFilterDropdownKeyboardThrottle';
import { useFilterDropdownRovingFocus } from './useFilterDropdownRovingFocus';

Expand Down Expand Up @@ -181,6 +185,9 @@ export function FilterDropdown(props: Readonly<FilterDropdownProps>) {

const getCategorySelectionCount = useCallback(
(category: FilterDropdownCategory) => {
if (isCategoryWithContent(category)) {
return category.selectionCount ?? 0;
}
if (!isDefined(category.items)) {
return 0;
}
Expand Down
61 changes: 56 additions & 5 deletions src/components/filters/FilterDropdownRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { Ref, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { KeyboardEvent, Ref, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { isDefined } from '~common/helpers/types';
import { SearchInput } from '../search-input';
Expand All @@ -28,8 +28,17 @@ import {
FilterDropdownMultiSelectList,
FilterDropdownSingleSelectList,
} from './FilterDropdownItemsList';
import { StyledRightPanel, StyledSearchHeader, StyledSpinnerWrapper } from './FilterDropdownStyles';
import { FilterDropdownCategory, FilterDropdownOption } from './FilterDropdownTypes';
import {
StyledCustomContent,
StyledRightPanel,
StyledSearchHeader,
StyledSpinnerWrapper,
} from './FilterDropdownStyles';
import {
FilterDropdownCategory,
FilterDropdownOption,
isCategoryWithContent,
} from './FilterDropdownTypes';

/** @internal */
export interface FilterDropdownRightPanelHandle {
Expand All @@ -54,11 +63,13 @@ interface FilterDropdownRightPanelProps {
/** @internal */
export function FilterDropdownRightPanel(props: Readonly<FilterDropdownRightPanelProps>) {
const { activeCategory, onCategoryFocusBack, onItemToggle, pendingValues, ref, items } = props;
const content = activeCategory?.content;
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const listRef = useRef<FilterDropdownItemsListHandle>(null);

const isLoadingItems = !isDefined(items);
const hasCustomContent = isCategoryWithContent(activeCategory);
const isLoadingItems = !hasCustomContent && !isDefined(items);

// When onSearch is provided, the consumer handles filtering and updates items directly.
// Client-side filtering is only applied when isSearchable is true but onSearch is absent.
Expand All @@ -76,7 +87,13 @@ export function FilterDropdownRightPanel(props: Readonly<FilterDropdownRightPane

// Expose imperative handle to parent, allowing it to focus the first item in this panel.
useImperativeHandle(ref, () => ({
focusFirstItem: () => listRef.current?.focusFirstItem(),
focusFirstItem: () => {
if (hasCustomContent) {
activeCategory?.onFocusContent();
} else {
listRef.current?.focusFirstItem();
}
},
}));
Comment thread
gregaubert marked this conversation as resolved.

const handleSearchChange = useCallback(
Expand All @@ -87,6 +104,40 @@ export function FilterDropdownRightPanel(props: Readonly<FilterDropdownRightPane
[activeCategory],
);

const handleCustomContentKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'ArrowLeft') {
return;
}
// Preserve native ArrowLeft behavior for text-editing elements (inputs,
// textareas, selects, and contenteditable nodes) so cursor movement is
// not interrupted. For all other focusable descendants (buttons, links,
// custom widgets, or the wrapper itself) ArrowLeft navigates back to the
// active category in the left panel.
const target = e.target as HTMLElement;
const tagName = target.tagName.toLowerCase();
if (
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select' ||
target.isContentEditable
) {
return;
}
e.preventDefault();
onCategoryFocusBack();
},
[onCategoryFocusBack],
);

if (hasCustomContent) {
return (
<StyledRightPanel>
<StyledCustomContent onKeyDown={handleCustomContentKeyDown}>{content}</StyledCustomContent>
</StyledRightPanel>
);
}

return (
<StyledRightPanel>
{activeCategory?.isSearchable && (
Expand Down
6 changes: 6 additions & 0 deletions src/components/filters/FilterDropdownStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ StyledItemsList.displayName = 'StyledItemsList';
export const StyledItemsListRadioGroup = StyledItemsList.withComponent(RadixRadioGroup.Root);
StyledItemsListRadioGroup.displayName = 'StyledItemsListRadioGroup';

export const StyledCustomContent = styled.div`
flex: 1 1 auto;
overflow: auto;
`;
StyledCustomContent.displayName = 'StyledCustomContent';

export const StyledSpinnerWrapper = styled.div`
display: flex;
align-items: center;
Expand Down
86 changes: 83 additions & 3 deletions src/components/filters/FilterDropdownTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import { ReactNode, Ref } from 'react';
import { isDefined } from '~common/helpers/types';
import { TextNodeOptional } from '~types/utils';

/**
Expand Down Expand Up @@ -49,10 +50,15 @@ export interface FilterDropdownOption {
}

/**
* A category shown in the left panel of a FilterDropdown.
* Each category has its own list of items, multi-select mode, and optional search.
* A standard category that renders a list of selectable items in the right panel.
* Use {@link FilterDropdownCategoryWithContent} to render arbitrary content instead.
*/
export interface FilterDropdownCategory {
export interface FilterDropdownCategoryWithItems {
/**
* Must not be set on an items-based category.
* Use {@link FilterDropdownCategoryWithContent} to render custom content.
*/
content?: never;
/**
* Whether items in this category support multi-selection (checkboxes).
* When false, only one item can be selected at a time (radio buttons).
Expand Down Expand Up @@ -88,6 +94,73 @@ export interface FilterDropdownCategory {
onSearch?: (query: string) => void;
}

/**
* A category that renders arbitrary custom content in the right panel instead of an items list.
* The FilterDropdown handles left↔right keyboard navigation (ArrowRight/ArrowLeft between panels),
* but navigation within the custom content is the consumer's responsibility.
* Use {@link FilterDropdownCategoryWithItems} to render a standard items list instead.
*/
export interface FilterDropdownCategoryWithContent {
/**
* Custom content to render in the right panel for this category.
* When set, `items`, `isMultiSelect`, `isSearchable`, `labelSearchPlaceholder`,
* and `onSearch` must not be provided.
*/
content: ReactNode;
/**
* Not applicable when using custom content.
* @see FilterDropdownCategoryWithItems
*/
isMultiSelect?: never;
/**
* Not applicable when using custom content.
* @see FilterDropdownCategoryWithItems
*/
isSearchable?: never;
/**
* Not applicable when using custom content.
* @see FilterDropdownCategoryWithItems
*/
items?: never;
/**
* Display label for this category.
*/
label: string;
/**
* Not applicable when using custom content.
* @see FilterDropdownCategoryWithItems
*/
labelSearchPlaceholder?: never;
/**
* Called when keyboard navigation moves focus into the custom content panel
* (e.g. when the user presses ArrowRight from the left panel).
* Use this to programmatically focus a specific element within your content
* (e.g. an input field or the first interactive control).
*/
onFocusContent: () => void;
/**
* Not applicable when using custom content.
* @see FilterDropdownCategoryWithItems
*/
onSearch?: never;
/**
* Number of active selections to display as a badge on the left-panel category button.
* Unlike items-based categories (where the count is derived automatically from `selectedValues`),
* custom-content categories cannot compute a count internally — the consumer must provide it.
* @defaultValue 0
*/
selectionCount?: number;
}

/**
* A category shown in the left panel of a FilterDropdown.
* Each category either renders a list of selectable items ({@link FilterDropdownCategoryWithItems})
* or arbitrary custom content ({@link FilterDropdownCategoryWithContent}) in the right panel.
*/
export type FilterDropdownCategory =
| FilterDropdownCategoryWithItems
| FilterDropdownCategoryWithContent;

/**
* Props for the FilterDropdown component.
*/
Expand Down Expand Up @@ -175,3 +248,10 @@ export interface FilterDropdownProps {
*/
selectedValues?: string[];
}

/** @internal */
export function isCategoryWithContent(
category?: FilterDropdownCategory,
): category is FilterDropdownCategoryWithContent {
return isDefined(category?.content);
}
47 changes: 47 additions & 0 deletions src/components/filters/__tests__/FilterDropdown-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,53 @@ describe('FilterDropdown', () => {
},
);

it('renders custom content in the right panel for a content category', async () => {
const { user } = renderFilterDropdown({
categories: [
{ label: 'Severity', items: [{ label: 'High', value: 'high' }] },
{
label: 'Date Range',
content: <button type="button">Pick date</button>,
onFocusContent: jest.fn(),
},
],
});

await user.click(screen.getByRole('button', { name: 'Filters' }));
await user.click(screen.getByRole('option', { name: /date range/i }));

expect(screen.getByRole('button', { name: 'Pick date' })).toBeInTheDocument();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});

it('moves focus into custom content on ArrowRight and back to the category on ArrowLeft', async () => {
const buttonRef = { current: null as HTMLButtonElement | null };
const { user } = renderFilterDropdown({
categories: [
{ label: 'Severity', items: [{ label: 'High', value: 'high' }] },
{
label: 'Date Range',
content: (
<button ref={buttonRef} type="button">
Pick date
</button>
),
onFocusContent: () => buttonRef.current?.focus(),
},
],
});

await user.click(screen.getByRole('button', { name: 'Filters' }));
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('option', { name: /date range/i })).toHaveFocus();

await user.keyboard('{ArrowRight}');
expect(screen.getByRole('button', { name: 'Pick date' })).toHaveFocus();

await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('option', { name: /date range/i })).toHaveFocus();
});

it('filters items when searching', async () => {
const { user } = renderFilterDropdown({
categories: [
Expand Down
2 changes: 2 additions & 0 deletions src/components/filters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export { FilterDropdown } from './FilterDropdown';

export type {
FilterDropdownCategory,
FilterDropdownCategoryWithContent,
FilterDropdownCategoryWithItems,
FilterDropdownOption,
FilterDropdownProps,
} from './FilterDropdownTypes';
Expand Down
30 changes: 29 additions & 1 deletion stories/filters/FilterDropdown-stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import type { Meta, StoryObj } from '@storybook/react-vite';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
FilterDropdown,
FilterDropdownCategory,
Expand Down Expand Up @@ -95,14 +95,35 @@ function withRandomCounts<T extends { label: string; value: string }>(
}));
}

function DateRangeContent({ ref }: Readonly<{ ref?: React.Ref<HTMLInputElement> }>) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '16px' }}>
<p style={{ margin: 0 }}>
This is a custom component rendered inside the right panel. Arrow keys navigate between
panels; interaction within is managed by the consumer.
</p>
<label style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{'From'}
<input ref={ref} type="date" />
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{'To'}
<input type="date" />
</label>
</div>
);
}

/**
* Builds the shared category set used by all stories:
* - Severity: multi-select, sync items
* - Type: single-select, sync items
* - Status: multi-select, client-side search, sync items
* - Assignee: multi-select, async items, server-side search
* - Date Range: custom content
*/
function useFilterDropdownCategories() {
const dateInputRef = useRef<HTMLInputElement>(null);
const [assigneeItems, setAssigneeItems] = useState<FilterDropdownCategory['items']>(undefined);

const handleCategorySelect = useCallback(
Expand Down Expand Up @@ -142,6 +163,13 @@ function useFilterDropdownCategories() {
labelSearchPlaceholder: 'Search assignees…',
onSearch: handleAssigneeSearch,
},
{
content: <DateRangeContent ref={dateInputRef} />,
onFocusContent: () => {
dateInputRef.current?.focus();
},
label: 'Date Range',
},
],
[assigneeItems, handleAssigneeSearch],
);
Expand Down
Loading