diff --git a/.vscode/settings.template.json b/.vscode/settings.template.json index 4dee0019e..a8182b3a5 100644 --- a/.vscode/settings.template.json +++ b/.vscode/settings.template.json @@ -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" } diff --git a/src/components/filters/FilterDropdown.tsx b/src/components/filters/FilterDropdown.tsx index f88bb0679..0ff5fa6c2 100644 --- a/src/components/filters/FilterDropdown.tsx +++ b/src/components/filters/FilterDropdown.tsx @@ -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'; @@ -181,6 +185,9 @@ export function FilterDropdown(props: Readonly) { const getCategorySelectionCount = useCallback( (category: FilterDropdownCategory) => { + if (isCategoryWithContent(category)) { + return category.selectionCount ?? 0; + } if (!isDefined(category.items)) { return 0; } diff --git a/src/components/filters/FilterDropdownRightPanel.tsx b/src/components/filters/FilterDropdownRightPanel.tsx index 0a485eb2b..4196e37ed 100644 --- a/src/components/filters/FilterDropdownRightPanel.tsx +++ b/src/components/filters/FilterDropdownRightPanel.tsx @@ -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'; @@ -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 { @@ -54,11 +63,13 @@ interface FilterDropdownRightPanelProps { /** @internal */ export function FilterDropdownRightPanel(props: Readonly) { const { activeCategory, onCategoryFocusBack, onItemToggle, pendingValues, ref, items } = props; + const content = activeCategory?.content; const { formatMessage } = useIntl(); const [searchQuery, setSearchQuery] = useState(''); const listRef = useRef(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. @@ -76,7 +87,13 @@ export function FilterDropdownRightPanel(props: Readonly ({ - focusFirstItem: () => listRef.current?.focusFirstItem(), + focusFirstItem: () => { + if (hasCustomContent) { + activeCategory?.onFocusContent(); + } else { + listRef.current?.focusFirstItem(); + } + }, })); const handleSearchChange = useCallback( @@ -87,6 +104,40 @@ export function FilterDropdownRightPanel(props: Readonly) => { + 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 ( + + {content} + + ); + } + return ( {activeCategory?.isSearchable && ( diff --git a/src/components/filters/FilterDropdownStyles.ts b/src/components/filters/FilterDropdownStyles.ts index 9f8b52fcf..13b907de8 100644 --- a/src/components/filters/FilterDropdownStyles.ts +++ b/src/components/filters/FilterDropdownStyles.ts @@ -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; diff --git a/src/components/filters/FilterDropdownTypes.ts b/src/components/filters/FilterDropdownTypes.ts index e12b5b6ab..c32dc31c6 100644 --- a/src/components/filters/FilterDropdownTypes.ts +++ b/src/components/filters/FilterDropdownTypes.ts @@ -19,6 +19,7 @@ */ import { ReactNode, Ref } from 'react'; +import { isDefined } from '~common/helpers/types'; import { TextNodeOptional } from '~types/utils'; /** @@ -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). @@ -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. */ @@ -175,3 +248,10 @@ export interface FilterDropdownProps { */ selectedValues?: string[]; } + +/** @internal */ +export function isCategoryWithContent( + category?: FilterDropdownCategory, +): category is FilterDropdownCategoryWithContent { + return isDefined(category?.content); +} diff --git a/src/components/filters/__tests__/FilterDropdown-test.tsx b/src/components/filters/__tests__/FilterDropdown-test.tsx index a094f64df..6416c2968 100644 --- a/src/components/filters/__tests__/FilterDropdown-test.tsx +++ b/src/components/filters/__tests__/FilterDropdown-test.tsx @@ -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: , + 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: ( + + ), + 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: [ diff --git a/src/components/filters/index.ts b/src/components/filters/index.ts index 427c3a3b8..52016beaa 100644 --- a/src/components/filters/index.ts +++ b/src/components/filters/index.ts @@ -22,6 +22,8 @@ export { FilterDropdown } from './FilterDropdown'; export type { FilterDropdownCategory, + FilterDropdownCategoryWithContent, + FilterDropdownCategoryWithItems, FilterDropdownOption, FilterDropdownProps, } from './FilterDropdownTypes'; diff --git a/stories/filters/FilterDropdown-stories.tsx b/stories/filters/FilterDropdown-stories.tsx index b0f51eb9f..32c585e8f 100644 --- a/stories/filters/FilterDropdown-stories.tsx +++ b/stories/filters/FilterDropdown-stories.tsx @@ -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, @@ -95,14 +95,35 @@ function withRandomCounts( })); } +function DateRangeContent({ ref }: Readonly<{ ref?: React.Ref }>) { + return ( +
+

+ This is a custom component rendered inside the right panel. Arrow keys navigate between + panels; interaction within is managed by the consumer. +

+ + +
+ ); +} + /** * 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(null); const [assigneeItems, setAssigneeItems] = useState(undefined); const handleCategorySelect = useCallback( @@ -142,6 +163,13 @@ function useFilterDropdownCategories() { labelSearchPlaceholder: 'Search assignees…', onSearch: handleAssigneeSearch, }, + { + content: , + onFocusContent: () => { + dateInputRef.current?.focus(); + }, + label: 'Date Range', + }, ], [assigneeItems, handleAssigneeSearch], );