From 19e7eaf43d8fd9941e466180072c2aadb4a51ccb Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 8 Sep 2026 13:10:35 +0200 Subject: [PATCH 1/6] Add reusable CheckboxListFilter with overflow-triggered sticky search FilterPanel's checkbox/radio option list was a one-off inline renderer whose inline search box only ever appeared when a filter explicitly opted in via `searchable: true` - so a long, unsearched list scrolled past dozens of rows with no way to narrow it. Extract that renderer into a standalone, exported CheckboxListFilter (Filter/CheckboxListFilter.tsx) so applications can reuse the same checkbox/radio picker outside a FilterPanel, following the pattern proven by Stagehand's bespoke CheckboxPicker escape hatch. The list is now bounded to a fixed, scrollable box (.pv-option-list), and `searchable` becomes a tri-state: `true`/`false` keep forcing the search box on or off exactly as before, while the new default (`undefined`) shows it only when the option list actually overflows its box - measured via useOptionListOverflow against an off-screen mirror of every option, so a live search filtering the visible rows never itself shrinks the measured content and un-decides that the box needed a search box in the first place. The search box is pinned to the top of the list's own scroll container via CSS position: sticky. FilterPanel's OptionList is now a thin adapter over CheckboxListFilter, keeping FilterPanelProps and FilterDefinition unchanged for existing consumers such as Chronicle Workbench. --- Source/Filter/CheckboxListFilter.tsx | 167 ++++++++++++++++++ Source/Filter/FilterPanel.css | 37 ++++ Source/Filter/FilterPanel.tsx | 75 ++------ .../when_rendering_state_attributes.tsx | 5 +- Source/Filter/index.ts | 2 + Source/Filter/types.ts | 9 +- Source/Filter/useOptionListOverflow.ts | 58 ++++++ Source/Filter/utils.ts | 22 +++ 8 files changed, 316 insertions(+), 59 deletions(-) create mode 100644 Source/Filter/CheckboxListFilter.tsx create mode 100644 Source/Filter/useOptionListOverflow.ts diff --git a/Source/Filter/CheckboxListFilter.tsx b/Source/Filter/CheckboxListFilter.tsx new file mode 100644 index 00000000..162519ca --- /dev/null +++ b/Source/Filter/CheckboxListFilter.tsx @@ -0,0 +1,167 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useId, useMemo, useRef, useState } from 'react'; +import type { FilterOption } from './types'; +import { useOptionListOverflow } from './useOptionListOverflow'; + +/** + * Props for {@link CheckboxListFilter}. + */ +export interface CheckboxListFilterProps { + /** Everything that can be picked. */ + options: FilterOption[]; + /** Keys of the currently selected options. */ + selected: Set; + /** Allow selecting more than one option (checkbox behaviour). Defaults to false (radio behaviour). */ + multi?: boolean; + /** Called with the toggled option's key whenever a row is picked. */ + onToggle: (optionKey: string) => void; + /** + * Whether to show a search box for narrowing the list by label. + * + * - `true` always shows it. + * - `false` never shows it, no matter how long the list gets. + * - `undefined` (the default) shows it only when there are more options than fit inside the + * list's box - the search box grows out of the list needing one, rather than the caller + * having to predict that ahead of time. + */ + searchable?: boolean; + /** Placeholder text for the search input. Defaults to 'Search…'. */ + searchPlaceholder?: string; + /** Shown in place of the list when there are no options at all. */ + emptyMessage?: string; + /** Shown in place of the list when a search matches nothing. */ + noMatchesMessage?: string; + /** + * The `name` attribute grouping single-select (`multi={false}`) options into one radio group. + * Generated automatically when omitted; only needs to be supplied to coordinate with markup + * outside this component. + */ + name?: string; +} + +function renderOptionCount(count: number | undefined): string | number { + return typeof count === 'number' ? count : ''; +} + +/** + * A bounded, scrollable list of checkboxes or radio buttons for picking from a set of options. + * + * The list is capped at a fixed height so it cannot swallow whatever it sits in. When picking + * from it would otherwise mean scrolling past more rows than fit in that box, a search input + * appears, pinned to the top of the list so it stays reachable while the rows beneath it scroll. + * A short list that already fits gets no search box at all - one is only ever shown because the + * list needs it, not because the caller guessed it might. + * + * Used by {@link FilterPanel} to render its string/option filter groups, and equally usable + * standalone wherever an application needs a checkbox/radio picker with the same behaviour. + * + * ```tsx + * toggleRepository(key)} + * /> + * ``` + * + * @param props - {@link CheckboxListFilterProps}. + */ +export function CheckboxListFilter({ + options, + selected, + multi = false, + onToggle, + searchable, + searchPlaceholder = 'Search…', + emptyMessage = 'Nothing to choose from.', + noMatchesMessage = 'No matches.', + name, +}: CheckboxListFilterProps) { + const [search, setSearch] = useState(''); + const containerRef = useRef(null); + const mirrorRef = useRef(null); + const generatedName = useId(); + const groupName = name ?? generatedName; + + const autoDetect = searchable === undefined; + const overflows = useOptionListOverflow(containerRef, mirrorRef, autoDetect); + const showSearch = searchable === true || (autoDetect && overflows); + + const normalized = search.trim().toLowerCase(); + const visibleOptions = useMemo( + () => + showSearch && normalized.length > 0 + ? options.filter((option) => option.label.toLowerCase().includes(normalized)) + : options, + [options, showSearch, normalized], + ); + + if (options.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ {showSearch && ( +
+ setSearch(event.target.value)} + /> +
+ )} +
    + {visibleOptions.map((option) => { + const checked = selected.has(option.key); + return ( +
  • + +
  • + ); + })} + {visibleOptions.length === 0 && ( +
  • + {noMatchesMessage} +
  • + )} +
+ {autoDetect && ( + // An off-screen clone of every option, in the same markup as the real rows above, + // so its height matches theirs exactly. Measuring this rather than the real list + // means a search that filters the visible rows down never shrinks the measured + // height and un-decides that the list needed a search box in the first place - see + // useOptionListOverflow. Absolutely positioned (see FilterPanel.css), so its place + // in the DOM does not affect layout. + + )} +
+ ); +} diff --git a/Source/Filter/FilterPanel.css b/Source/Filter/FilterPanel.css index 09c3acb5..44db9d0c 100644 --- a/Source/Filter/FilterPanel.css +++ b/Source/Filter/FilterPanel.css @@ -146,6 +146,43 @@ box-shadow: var(--cratis-focus-ring); } +/* Reusable checkbox/radio option list (CheckboxListFilter) */ +.pv-option-list { + position: relative; + display: flex; + flex-direction: column; + max-height: var(--cratis-filter-option-list-max-height, 14rem); + overflow-y: auto; +} + +/* Off-screen clone of every option, used only to measure whether the real (possibly + search-filtered) list would overflow the box - see useOptionListOverflow. */ +.pv-option-list-mirror { + position: absolute; + inset: 0; + margin: 0; + padding: 0; + list-style: none; + visibility: hidden; + pointer-events: none; + max-height: none; + overflow: visible; +} + +.pv-option-list-search { + position: sticky; + top: 0; + z-index: 1; + background: var(--cratis-surface-overlay); +} + +.pv-option-list-empty { + margin: 0; + padding: 0.45rem 0.5rem; + font-size: 0.85rem; + color: var(--cratis-text-color-secondary); +} + .pv-filter-groups { display: flex; flex-direction: column; diff --git a/Source/Filter/FilterPanel.tsx b/Source/Filter/FilterPanel.tsx index 83d8f33c..c83871e5 100644 --- a/Source/Filter/FilterPanel.tsx +++ b/Source/Filter/FilterPanel.tsx @@ -23,6 +23,7 @@ import { resolveDropdownPosition, type DropdownPosition } from './utils'; import type { FilterEditorProps } from './FilterEditorProps'; import { FilterEditor } from './FilterEditor'; import { RangeHistogramFilter } from './RangeHistogramFilter'; +import { CheckboxListFilter } from './CheckboxListFilter'; /** * Props for {@link FilterPanel}. @@ -113,8 +114,11 @@ function buildEditorMap( * * ## Filter types * - * - **String/option filters** render as a list of checkboxes or radio buttons. - * Controlled through `filterValues`. + * - **String/option filters** render as a {@link CheckboxListFilter} - a bounded, + * scrollable list of checkboxes or radio buttons. Controlled through `filterValues`. + * A search box grows out of the list automatically once it has more options than + * fit in its box; see {@link FilterDefinition.searchable} to force it always on + * or always off instead. * - **Numeric/date range filters** render as a {@link RangeHistogramFilter} * with a draggable range selector over a histogram. Controlled through * `rangeValues`. @@ -148,10 +152,6 @@ function buildEditorMap( * * @param props - {@link FilterPanelProps}. */ -function renderOptionCount(count: number | undefined): string | number { - return typeof count === 'number' ? count : ''; -} - interface OptionListProps { filter: FilterDefinition; selections: Set; @@ -160,64 +160,25 @@ interface OptionListProps { searchPlaceholder?: string; } +/** Adapts a `FilterDefinition`'s string/option shape onto the reusable {@link CheckboxListFilter}. */ function OptionList({ filter, selections, onFilterToggle, searchPlaceholder, }: Omit) { - const [groupSearch, setGroupSearch] = useState(''); - const allOptions = filter.options ?? []; - const normalized = groupSearch.trim().toLowerCase(); - const visibleOptions = - filter.searchable && normalized.length > 0 - ? allOptions.filter((option) => - option.label.toLowerCase().includes(normalized), - ) - : allOptions; - return ( - <> - {filter.searchable && ( -
- setGroupSearch(event.target.value)} - /> -
- )} -
    - {visibleOptions.map((option) => { - const optionKey = option.key; - const checked = selections.has(optionKey); - return ( -
  • - -
  • - ); - })} -
- + + onFilterToggle(filter.key, optionKey, filter.multi ?? false) + } + /> ); } diff --git a/Source/Filter/for_FilterPanel/when_rendering_state_attributes.tsx b/Source/Filter/for_FilterPanel/when_rendering_state_attributes.tsx index 09d5aa8b..d930e0c7 100644 --- a/Source/Filter/for_FilterPanel/when_rendering_state_attributes.tsx +++ b/Source/Filter/for_FilterPanel/when_rendering_state_attributes.tsx @@ -89,7 +89,10 @@ describe('when rendering FilterPanel state attributes', () => { }); it('should expose selected state only on the selected option', () => { - const options = document.querySelectorAll('.pv-filter li'); + // Scoped to the real, interactive rows: an option list with no explicit `searchable` + // also renders an `aria-hidden` measuring mirror of the same options (used to decide + // whether it needs a search box), which `.pv-filter li` would otherwise also match. + const options = document.querySelectorAll('.pv-option-list-options li'); const selected = options[0]; const unselected = options[1]; diff --git a/Source/Filter/index.ts b/Source/Filter/index.ts index 9b5c8506..7dcbe7d5 100644 --- a/Source/Filter/index.ts +++ b/Source/Filter/index.ts @@ -3,6 +3,8 @@ export { FilterPanel } from './FilterPanel'; export type { FilterPanelProps } from './FilterPanel'; +export { CheckboxListFilter } from './CheckboxListFilter'; +export type { CheckboxListFilterProps } from './CheckboxListFilter'; export { FilterEditor } from './FilterEditor'; export type { FilterEditorSlotProps } from './FilterEditor'; export type { FilterEditorProps } from './FilterEditorProps'; diff --git a/Source/Filter/types.ts b/Source/Filter/types.ts index dbe9060c..8f1c24d7 100644 --- a/Source/Filter/types.ts +++ b/Source/Filter/types.ts @@ -56,7 +56,14 @@ export interface FilterDefinition { }; /** Number of histogram buckets. Defaults to 20. */ buckets?: number; - /** Show an inline search box that filters the displayed options for this group. */ + /** + * Whether to show an inline search box that filters this group's options by label. + * + * - `true` always shows it. + * - `false` never shows it, no matter how long the option list gets. + * - `undefined` (the default) shows it only when there are more options than fit inside the + * group's box, pinned to the top of the list while the options beneath it scroll. + */ searchable?: boolean; /** Placeholder shown in the inline search box. Defaults to 'Search…'. */ searchPlaceholder?: string; diff --git a/Source/Filter/useOptionListOverflow.ts b/Source/Filter/useOptionListOverflow.ts new file mode 100644 index 00000000..846b7a71 --- /dev/null +++ b/Source/Filter/useOptionListOverflow.ts @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useLayoutEffect, useState } from 'react'; +import type { RefObject } from 'react'; +import { optionListOverflows } from './utils'; + +/** + * Tracks whether an option list's full content is taller than the box it renders in. + * + * `contentRef` should point at an off-screen element holding every option - not the live, + * possibly search-filtered list - so a user typing into the search box this hook decided to show + * never itself shrinks the measured content below the threshold and un-decides that the box + * needed a search in the first place. `containerRef`'s own `scrollHeight`/`clientHeight` are not + * used for the same reason: they move as the search box appears and disappears, which would make + * the decision chase itself. Instead the container's resolved CSS `max-height` is read directly, + * since that value does not depend on what is currently rendered inside it. + * + * Pass `enabled: false` to skip measuring entirely - for callers that already know whether they + * want a search box and do not need this hook to decide for them. + */ +export function useOptionListOverflow( + containerRef: RefObject, + contentRef: RefObject, + enabled: boolean, +): boolean { + const [overflows, setOverflows] = useState(false); + + useLayoutEffect(() => { + if (!enabled) { + setOverflows(false); + return; + } + + const container = containerRef.current; + const content = contentRef.current; + if (!container || !content) return; + + const measure = () => { + const maxHeight = parseFloat(getComputedStyle(container).maxHeight) || 0; + setOverflows( + optionListOverflows({ contentHeight: content.scrollHeight, maxHeight }), + ); + }; + + measure(); + + if (typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(measure); + observer.observe(container); + observer.observe(content); + + return () => observer.disconnect(); + }, [containerRef, contentRef, enabled]); + + return overflows; +} diff --git a/Source/Filter/utils.ts b/Source/Filter/utils.ts index 754d46f5..e3a93a01 100644 --- a/Source/Filter/utils.ts +++ b/Source/Filter/utils.ts @@ -26,6 +26,14 @@ export interface DropdownViewport { height: number; } +/** The measured height budget behind the decision to show an option list's search box. */ +export interface OptionListOverflowMetrics { + /** The combined natural height of every option row, in pixels. */ + contentHeight: number; + /** The maximum height the option list box is allowed to grow to, in pixels. */ + maxHeight: number; +} + /** Fixed-position coordinates and bounds for the filter dropdown. */ export interface DropdownPosition { left: number; @@ -106,6 +114,20 @@ export function resolveDropdownPosition( }; } +/** + * Whether an option list has more rows than fit inside its box - the signal a filter group uses to + * grow a search box the caller did not explicitly ask for. `maxHeight` of zero means the box has not + * been measured yet (e.g. before first layout), so the answer defaults to "not overflowing" rather + * than flashing a search box speculatively. Pulled out of the measuring hook so the decision is + * testable on plain numbers, without a real layout engine. + */ +export function optionListOverflows({ + contentHeight, + maxHeight, +}: OptionListOverflowMetrics): boolean { + return maxHeight > 0 && contentHeight > maxHeight; +} + /** Initialise the string/option selection map for all string/date filters. */ export function buildFilterValues(filters: FilterDefinition[] | undefined): FilterValues { const state: FilterValues = {}; From d3ac9dcb2b68dafd36436026a47f9a66abdb00d2 Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 8 Sep 2026 13:10:47 +0200 Subject: [PATCH 2/6] Add specs for CheckboxListFilter's overflow-triggered search Covers the pure overflow decision (optionListOverflows) on plain numbers, plus CheckboxListFilter itself: no search box for a list that fits, a sticky search box pinned to the top of the box once the list overflows, search/false/true forcing that decision explicitly, label filtering (case-insensitive, no-matches message), and checkbox/radio selection and toggling. The DOM-based specs stub ResizeObserver, Element.scrollHeight, and getComputedStyle(...).maxHeight the same way other suites in this package work around jsdom's missing layout engine, letting a spec dictate whether the option list "overflows" without a real browser. --- .../a_checkbox_list_filter_in_the_dom.ts | 122 ++++++++++++++++++ .../when_filtering_options_by_search_text.tsx | 82 ++++++++++++ .../when_options_fit_without_scrolling.tsx | 47 +++++++ .../when_options_overflow_the_box.tsx | 57 ++++++++ ...search_visibility_is_explicitly_forced.tsx | 79 ++++++++++++ .../when_toggling_option_selection.tsx | 94 ++++++++++++++ ...ether_an_option_list_needs_a_search_box.ts | 32 +++++ 7 files changed, 513 insertions(+) create mode 100644 Source/Filter/for_CheckboxListFilter/given/a_checkbox_list_filter_in_the_dom.ts create mode 100644 Source/Filter/for_CheckboxListFilter/when_filtering_options_by_search_text.tsx create mode 100644 Source/Filter/for_CheckboxListFilter/when_options_fit_without_scrolling.tsx create mode 100644 Source/Filter/for_CheckboxListFilter/when_options_overflow_the_box.tsx create mode 100644 Source/Filter/for_CheckboxListFilter/when_search_visibility_is_explicitly_forced.tsx create mode 100644 Source/Filter/for_CheckboxListFilter/when_toggling_option_selection.tsx create mode 100644 Source/Filter/for_optionListOverflows/when_deciding_whether_an_option_list_needs_a_search_box.ts diff --git a/Source/Filter/for_CheckboxListFilter/given/a_checkbox_list_filter_in_the_dom.ts b/Source/Filter/for_CheckboxListFilter/given/a_checkbox_list_filter_in_the_dom.ts new file mode 100644 index 00000000..cac77293 --- /dev/null +++ b/Source/Filter/for_CheckboxListFilter/given/a_checkbox_list_filter_in_the_dom.ts @@ -0,0 +1,122 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act } from 'react'; +import type { ReactElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +/** A `CheckboxListFilter` mounted into a real document, together with what is needed to take it down again. */ +export interface CheckboxListFilterInTheDom { + container: HTMLDivElement; + root: Root; +} + +/** + * Renders an element into a real document. + * @param element - The element to render. + * @returns The mounted tree, to be passed to {@link unmount}. + */ +export const render = async (element: ReactElement): Promise => { + // SAFETY: React's test-environment flag is an intentionally undocumented global absent from DOM typings. + ( + globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(element); + }); + + return { container, root }; +}; + +/** + * Unmounts a tree rendered with {@link render} and removes its container. + * @param mounted - The mounted tree. + */ +export const unmount = async (mounted: CheckboxListFilterInTheDom) => { + await act(async () => { + mounted.root.unmount(); + }); + mounted.container.remove(); +}; + +/** + * Types into a controlled search input the way a browser does. Assigning `input.value` directly + * bypasses the setter React's input tracking patches, so React never sees the change and no + * `onChange` fires; going through the native prototype's setter first is what makes the + * subsequent `input` event actually reach the component. + * @param input - The input to type into. + * @param value - The value to set. + */ +export const typeIntoSearchInput = async (input: HTMLInputElement, value: string) => { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + value, + ); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +}; + +/** + * Stubs the browser layout primitives `useOptionListOverflow` depends on and jsdom does not + * implement, so a spec can dictate whether the option list "overflows" without a real layout + * engine: + * + * - `ResizeObserver` - jsdom has no implementation at all; a no-op double is enough because + * these specs only ever need the *initial*, synchronous measurement the hook already performs + * on mount, not a live resize. + * - `Element.scrollHeight` - jsdom's layout-free DOM always reports zero, so the option list's + * off-screen measuring mirror (`.pv-option-list-mirror`) would never appear to hold any content. + * - `getComputedStyle(...).maxHeight` - the box's height budget comes from an external stylesheet + * this test environment never loads, so it otherwise resolves to the browser's initial `none`. + * + * Both stubs are installed *before* the component mounts, so the hook's first, synchronous + * measurement already sees the values a spec configures - no extra render pass required. Call the + * returned function to restore the originals once the spec is done with them. + * @param mirrorScrollHeight - The height, in pixels, the measuring mirror reports. + * @param containerMaxHeight - The `max-height` the option list's container reports, e.g. `'224px'`. + * @returns A function that restores the original, unstubbed browser behaviour. + */ +export const stubOptionListLayoutMeasurement = ( + mirrorScrollHeight: number, + containerMaxHeight: string, +): (() => void) => { + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + }; + + const originalScrollHeight = Object.getOwnPropertyDescriptor( + Element.prototype, + 'scrollHeight', + ); + Object.defineProperty(Element.prototype, 'scrollHeight', { + configurable: true, + get(this: Element) { + return this.classList.contains('pv-option-list-mirror') ? mirrorScrollHeight : 0; + }, + }); + + const originalGetComputedStyle = window.getComputedStyle; + window.getComputedStyle = ((element: Element, ...rest: unknown[]) => + element.classList?.contains('pv-option-list') + ? ({ maxHeight: containerMaxHeight } as CSSStyleDeclaration) + : ( + originalGetComputedStyle as unknown as ( + ...args: unknown[] + ) => CSSStyleDeclaration + )(element, ...rest)) as typeof window.getComputedStyle; + + return () => { + if (originalScrollHeight) { + Object.defineProperty(Element.prototype, 'scrollHeight', originalScrollHeight); + } + window.getComputedStyle = originalGetComputedStyle; + }; +}; diff --git a/Source/Filter/for_CheckboxListFilter/when_filtering_options_by_search_text.tsx b/Source/Filter/for_CheckboxListFilter/when_filtering_options_by_search_text.tsx new file mode 100644 index 00000000..e137abcd --- /dev/null +++ b/Source/Filter/for_CheckboxListFilter/when_filtering_options_by_search_text.tsx @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { CheckboxListFilter } from '../CheckboxListFilter'; +import type { CheckboxListFilterInTheDom } from './given/a_checkbox_list_filter_in_the_dom'; +import { render, typeIntoSearchInput, unmount } from './given/a_checkbox_list_filter_in_the_dom'; + +const options = [ + { key: 'engineering', label: 'Engineering', value: 'engineering' }, + { key: 'design', label: 'Design', value: 'design' }, + { key: 'sales', label: 'Sales', value: 'sales' }, +]; + +const optionLabels = (container: HTMLElement) => + Array.from( + container.querySelectorAll('.pv-option-list-options > li:not(.pv-option-list-empty)'), + ).map((row) => row.querySelector('span')?.textContent); + +// `searchable: true` forces the search box on regardless of whether the list overflows, so this +// suite can exercise filtering without needing to fake the browser layout `CheckboxListFilter` +// otherwise relies on to decide that for itself - see `for_CheckboxListFilter/when_options_overflow_the_box`. +describe('when filtering an option list by search text', () => { + let mounted: CheckboxListFilterInTheDom; + + beforeEach(async () => { + mounted = await render( + undefined} + />, + ); + }); + + afterEach(async () => { + await unmount(mounted); + }); + + it('should show every option before any search text is entered', () => { + expect(optionLabels(mounted.container)).to.deep.equal([ + 'Engineering', + 'Design', + 'Sales', + ]); + }); + + it('should narrow the list to options whose label matches the search text', async () => { + const input = mounted.container.querySelector( + '.pv-option-list-search input', + ) as HTMLInputElement; + + await typeIntoSearchInput(input, 'des'); + + expect(optionLabels(mounted.container)).to.deep.equal(['Design']); + }); + + it('should match case-insensitively', async () => { + const input = mounted.container.querySelector( + '.pv-option-list-search input', + ) as HTMLInputElement; + + await typeIntoSearchInput(input, 'ENGIN'); + + expect(optionLabels(mounted.container)).to.deep.equal(['Engineering']); + }); + + it('should show a no-matches message when nothing matches', async () => { + const input = mounted.container.querySelector( + '.pv-option-list-search input', + ) as HTMLInputElement; + + await typeIntoSearchInput(input, 'zzz'); + + expect(optionLabels(mounted.container)).to.deep.equal([]); + expect(mounted.container.querySelector('.pv-option-list-empty')).not.to.equal(null); + }); +}); diff --git a/Source/Filter/for_CheckboxListFilter/when_options_fit_without_scrolling.tsx b/Source/Filter/for_CheckboxListFilter/when_options_fit_without_scrolling.tsx new file mode 100644 index 00000000..ca30b2e2 --- /dev/null +++ b/Source/Filter/for_CheckboxListFilter/when_options_fit_without_scrolling.tsx @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { CheckboxListFilter } from '../CheckboxListFilter'; +import type { CheckboxListFilterInTheDom } from './given/a_checkbox_list_filter_in_the_dom'; +import { + render, + stubOptionListLayoutMeasurement, + unmount, +} from './given/a_checkbox_list_filter_in_the_dom'; + +const options = [ + { key: 'active', label: 'Active', value: 'active' }, + { key: 'inactive', label: 'Inactive', value: 'inactive' }, +]; + +describe('when an option list fits inside its box without scrolling', () => { + let mounted: CheckboxListFilterInTheDom; + let restoreMeasurement: () => void; + + beforeEach(async () => { + // The mirror's measured content (100px) is well inside the box (224px) - no scrolling needed. + restoreMeasurement = stubOptionListLayoutMeasurement(100, '224px'); + mounted = await render( + undefined} />, + ); + }); + + afterEach(async () => { + await unmount(mounted); + restoreMeasurement(); + }); + + it('should not render a search box', () => { + expect(mounted.container.querySelector('.pv-option-list-search')).to.equal(null); + }); + + it('should still render every option', () => { + expect( + mounted.container.querySelectorAll('.pv-option-list-options > li'), + ).to.have.lengthOf(2); + }); +}); diff --git a/Source/Filter/for_CheckboxListFilter/when_options_overflow_the_box.tsx b/Source/Filter/for_CheckboxListFilter/when_options_overflow_the_box.tsx new file mode 100644 index 00000000..09cb1298 --- /dev/null +++ b/Source/Filter/for_CheckboxListFilter/when_options_overflow_the_box.tsx @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { CheckboxListFilter } from '../CheckboxListFilter'; +import type { CheckboxListFilterInTheDom } from './given/a_checkbox_list_filter_in_the_dom'; +import { + render, + stubOptionListLayoutMeasurement, + unmount, +} from './given/a_checkbox_list_filter_in_the_dom'; + +const options = Array.from({ length: 20 }, (_, index) => ({ + key: `option-${index}`, + label: `Option ${index}`, + value: index, +})); + +describe('when an option list overflows its box', () => { + let mounted: CheckboxListFilterInTheDom; + let restoreMeasurement: () => void; + + beforeEach(async () => { + // The mirror's measured content (600px) is taller than the box (224px) - it needs to scroll. + restoreMeasurement = stubOptionListLayoutMeasurement(600, '224px'); + mounted = await render( + undefined} />, + ); + }); + + afterEach(async () => { + await unmount(mounted); + restoreMeasurement(); + }); + + it('should render a search box', () => { + expect(mounted.container.querySelector('.pv-option-list-search input')).not.to.equal( + null, + ); + }); + + it('should place the search box as the first child of the scrolling box, so it stays pinned to the top', () => { + const scrollBox = mounted.container.querySelector('.pv-option-list'); + expect(scrollBox?.firstElementChild?.classList.contains('pv-option-list-search')).to.equal( + true, + ); + }); + + it('should still render every option', () => { + expect( + mounted.container.querySelectorAll('.pv-option-list-options > li'), + ).to.have.lengthOf(20); + }); +}); diff --git a/Source/Filter/for_CheckboxListFilter/when_search_visibility_is_explicitly_forced.tsx b/Source/Filter/for_CheckboxListFilter/when_search_visibility_is_explicitly_forced.tsx new file mode 100644 index 00000000..d5826472 --- /dev/null +++ b/Source/Filter/for_CheckboxListFilter/when_search_visibility_is_explicitly_forced.tsx @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { expect } from 'chai'; +import { afterEach, describe, it } from 'vitest'; +import { CheckboxListFilter } from '../CheckboxListFilter'; +import type { CheckboxListFilterInTheDom } from './given/a_checkbox_list_filter_in_the_dom'; +import { + render, + stubOptionListLayoutMeasurement, + unmount, +} from './given/a_checkbox_list_filter_in_the_dom'; + +const fewOptions = [ + { key: 'active', label: 'Active', value: 'active' }, + { key: 'inactive', label: 'Inactive', value: 'inactive' }, +]; + +const manyOptions = Array.from({ length: 20 }, (_, index) => ({ + key: `option-${index}`, + label: `Option ${index}`, + value: index, +})); + +// `searchable` is a tri-state: `undefined` decides automatically (covered by +// `when_options_fit_without_scrolling` and `when_options_overflow_the_box`), while `true`/`false` +// override that decision explicitly, the same way the pre-existing FilterDefinition contract did. +describe('when a caller forces search visibility explicitly', () => { + let mounted: CheckboxListFilterInTheDom; + let restoreMeasurement: (() => void) | undefined; + + afterEach(async () => { + await unmount(mounted); + restoreMeasurement?.(); + restoreMeasurement = undefined; + }); + + it('should show the search box for a short list when searchable is true', async () => { + mounted = await render( + undefined} + />, + ); + + expect(mounted.container.querySelector('.pv-option-list-search')).not.to.equal(null); + }); + + it('should hide the search box for an overflowing list when searchable is false', async () => { + restoreMeasurement = stubOptionListLayoutMeasurement(600, '224px'); + mounted = await render( + undefined} + />, + ); + + expect(mounted.container.querySelector('.pv-option-list-search')).to.equal(null); + }); + + it('should not render the measuring mirror when search visibility is forced', async () => { + mounted = await render( + undefined} + />, + ); + + expect(mounted.container.querySelector('.pv-option-list-mirror')).to.equal(null); + }); +}); diff --git a/Source/Filter/for_CheckboxListFilter/when_toggling_option_selection.tsx b/Source/Filter/for_CheckboxListFilter/when_toggling_option_selection.tsx new file mode 100644 index 00000000..795fa3e7 --- /dev/null +++ b/Source/Filter/for_CheckboxListFilter/when_toggling_option_selection.tsx @@ -0,0 +1,94 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { act } from 'react'; +import { expect } from 'chai'; +import { afterEach, describe, it } from 'vitest'; +import { CheckboxListFilter } from '../CheckboxListFilter'; +import type { CheckboxListFilterInTheDom } from './given/a_checkbox_list_filter_in_the_dom'; +import { render, unmount } from './given/a_checkbox_list_filter_in_the_dom'; + +const options = [ + { key: 'active', label: 'Active', value: 'active' }, + { key: 'inactive', label: 'Inactive', value: 'inactive' }, +]; + +const inputFor = (container: HTMLElement, key: string) => + container.querySelectorAll('.pv-option-list-options > li')[ + options.findIndex((option) => option.key === key) + ]?.querySelector('input') as HTMLInputElement; + +describe('when toggling option selection in a multi-select checkbox list', () => { + let mounted: CheckboxListFilterInTheDom; + let toggled: string[]; + + afterEach(async () => { + await unmount(mounted); + }); + + const renderMulti = async (selected: Set) => { + toggled = []; + mounted = await render( + toggled.push(key)} + />, + ); + }; + + it('should render checkboxes', async () => { + await renderMulti(new Set()); + + expect(inputFor(mounted.container, 'active').type).to.equal('checkbox'); + }); + + it('should check the input for a selected option', async () => { + await renderMulti(new Set(['active'])); + + expect(inputFor(mounted.container, 'active').checked).to.equal(true); + expect(inputFor(mounted.container, 'inactive').checked).to.equal(false); + }); + + it('should expose data-selected only on the selected row', async () => { + await renderMulti(new Set(['active'])); + + const rows = mounted.container.querySelectorAll('.pv-option-list-options > li'); + expect(rows[0].getAttribute('data-selected')).to.equal('true'); + expect(rows[1].hasAttribute('data-selected')).to.equal(false); + }); + + it('should call onToggle with the toggled option key', async () => { + await renderMulti(new Set()); + + await act(async () => { + inputFor(mounted.container, 'inactive').click(); + }); + + expect(toggled).to.deep.equal(['inactive']); + }); +}); + +describe('when toggling option selection in a single-select radio list', () => { + let mounted: CheckboxListFilterInTheDom; + + afterEach(async () => { + await unmount(mounted); + }); + + it('should render radio buttons grouped under one name', async () => { + mounted = await render( + undefined} />, + ); + + const active = inputFor(mounted.container, 'active'); + const inactive = inputFor(mounted.container, 'inactive'); + + expect(active.type).to.equal('radio'); + expect(active.name).not.to.equal(''); + expect(active.name).to.equal(inactive.name); + }); +}); diff --git a/Source/Filter/for_optionListOverflows/when_deciding_whether_an_option_list_needs_a_search_box.ts b/Source/Filter/for_optionListOverflows/when_deciding_whether_an_option_list_needs_a_search_box.ts new file mode 100644 index 00000000..c610148f --- /dev/null +++ b/Source/Filter/for_optionListOverflows/when_deciding_whether_an_option_list_needs_a_search_box.ts @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { expect } from 'chai'; +import { describe, it } from 'vitest'; +import { optionListOverflows } from '../utils'; + +describe('when deciding whether an option list needs a search box', () => { + it('should overflow when the content is taller than the box', () => { + expect( + optionListOverflows({ contentHeight: 400, maxHeight: 224 }), + ).to.equal(true); + }); + + it('should not overflow when the content fits inside the box', () => { + expect( + optionListOverflows({ contentHeight: 100, maxHeight: 224 }), + ).to.equal(false); + }); + + it('should not overflow when the content exactly fills the box', () => { + expect( + optionListOverflows({ contentHeight: 224, maxHeight: 224 }), + ).to.equal(false); + }); + + it('should not overflow when the box has not been measured yet', () => { + expect( + optionListOverflows({ contentHeight: 400, maxHeight: 0 }), + ).to.equal(false); + }); +}); From c1928f9359fd34930363eee3c5d2847d99b9da23 Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 8 Sep 2026 13:10:59 +0200 Subject: [PATCH 3/6] Add Storybook stories for CheckboxListFilter's sticky search CheckboxListFilter.stories.tsx demonstrates the standalone component: a short list with no search box, a 30-option list whose search box grows out of the overflow and filters/selects correctly, and both explicit searchable overrides. Two new FilterPanel stories show the same behaviour through the full anchored panel - a 30-repository filter that grows its own search box next to a 3-option filter that never does. --- Source/Filter/CheckboxListFilter.stories.tsx | 125 ++++++++++++++ Source/Filter/FilterPanel.stories.tsx | 165 +++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 Source/Filter/CheckboxListFilter.stories.tsx diff --git a/Source/Filter/CheckboxListFilter.stories.tsx b/Source/Filter/CheckboxListFilter.stories.tsx new file mode 100644 index 00000000..cca7746b --- /dev/null +++ b/Source/Filter/CheckboxListFilter.stories.tsx @@ -0,0 +1,125 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import { CheckboxListFilter } from './CheckboxListFilter'; +import type { FilterOption } from './types'; + +const fewOptions: FilterOption[] = [ + { key: 'active', label: 'Active', value: 'active', count: 42 }, + { key: 'inactive', label: 'Inactive', value: 'inactive', count: 18 }, + { key: 'pending', label: 'Pending', value: 'pending', count: 7 }, +]; + +const manyOptions: FilterOption[] = Array.from({ length: 30 }, (_, index) => ({ + key: `repo-${index}`, + label: `repository-${String(index + 1).padStart(2, '0')}`, + value: `repo-${index}`, +})); + +const meta = { + title: 'Filter/CheckboxListFilter', + component: CheckboxListFilter, + args: { + options: fewOptions, + selected: new Set(), + onToggle: fn(), + }, + parameters: { layout: 'centered' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * A short list fits inside the box on its own, so no search box is rendered - not hidden, + * not disabled, simply not needed. + */ +export const FitsWithoutSearch: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByPlaceholderText('Search…')).toBeNull(); + await expect(canvas.getByRole('radio', { name: /^Active/ })).toBeTruthy(); + }, + render: (args) => { + const [selected, setSelected] = useState>(args.selected); + return ( +
+ { + args.onToggle(key); + setSelected((current) => new Set(current.has(key) ? [] : [key])); + }} + /> +
+ ); + }, +}; + +/** + * A long list overflows the box, so a search input grows out of the list on its own and stays + * pinned to the top of the scrolling area while the rows beneath it scroll. + */ +export const OverflowsWithStickySearch: Story = { + args: { + options: manyOptions, + multi: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const search = await canvas.findByPlaceholderText('Search…'); + await userEvent.type(search, 'repository-07'); + const match = await canvas.findByRole('checkbox', { name: /^repository-07/ }); + await userEvent.click(match); + await expect(match).toBeChecked(); + await expect(canvas.queryByText('repository-01')).toBeNull(); + }, + render: (args) => { + const [selected, setSelected] = useState>(args.selected); + return ( +
+ { + args.onToggle(key); + setSelected((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }} + /> +
+ ); + }, +}; + +/** `searchable={false}` forces the search box off even for a list that would otherwise overflow. */ +export const SearchForcedOff: Story = { + args: { + options: manyOptions, + multi: true, + searchable: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByPlaceholderText('Search…')).toBeNull(); + }, +}; + +/** `searchable` (true) forces the search box on even for a list short enough to fit on its own. */ +export const SearchForcedOn: Story = { + args: { + searchable: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByPlaceholderText('Search…')).toBeTruthy(); + }, +}; diff --git a/Source/Filter/FilterPanel.stories.tsx b/Source/Filter/FilterPanel.stories.tsx index 9b7818f6..daab55a9 100644 --- a/Source/Filter/FilterPanel.stories.tsx +++ b/Source/Filter/FilterPanel.stories.tsx @@ -687,3 +687,168 @@ export const MixedFilters: Story = { ); }, }; + +// --------------------------------------------------------------------------- +// Story: Option list search grows out of overflow, not a flag +// --------------------------------------------------------------------------- + +const repositories = Array.from({ length: 30 }, (_, index) => ({ + key: `repo-${index}`, + label: `repository-${String(index + 1).padStart(2, '0')}`, + value: `repo-${index}`, +})); + +export const AutoSearchWhenOverflowing: Story = { + name: 'Search appears only when the list overflows', + play: async ({ canvasElement }) => { + const { body } = await openFilterGroup(canvasElement, 'Repository', 'Repository'); + + // 30 repositories do not fit in the option list's box, so a search box appears on + // its own - no `searchable` flag was set on this filter definition. + const search = await body.findByPlaceholderText('Search…'); + await expect(search).toBeTruthy(); + + await userEvent.type(search, 'repository-05'); + const match = await body.findByRole('checkbox', { name: /^repository-05/ }); + await userEvent.click(match); + await expect(match).toBeChecked(); + + // Every other repository is filtered out of view while the search text narrows the list. + await expect(body.queryByText('repository-01')).toBeNull(); + }, + render: () => { + const buttonRef = useRef(null!); + const [isOpen, setIsOpen] = useState(false); + + const filters: FilterDefinition[] = useMemo(() => [ + { + key: 'repository', + label: 'Repository', + type: 'string', + multi: true, + options: repositories, + }, + ], []); + + const { filterValues, rangeValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange } = + useFilterState(filters); + + const activeCount = filterValues['repository']?.size ?? 0; + + return ( +
+
+

+ Search Appears Only When Needed +

+

+ 30 options overflow the option list's box, so a search box grows + out of the list automatically and stays pinned to the top while the + rows beneath it scroll. No searchable flag is set on this + filter — compare with "Search never appears for a short list". +

+
+
+ +
+ setIsOpen(false)} + onFilterToggle={handleToggleFilter} + onFilterClear={handleClearFilter} + onRangeChange={handleRangeChange} + onExpandedFilterChange={setExpandedFilterKey} + /> +
+ ); + }, +}; + +// --------------------------------------------------------------------------- +// Story: Option list search never appears for a short list +// --------------------------------------------------------------------------- + +export const NoSearchWhenItFits: Story = { + name: 'Search never appears for a short list', + play: async ({ canvasElement }) => { + const { body, canvas } = await openFilterGroup(canvasElement, 'Priority', 'Priority'); + + // Three options fit comfortably in the option list's box, so no search box is + // rendered at all - not hidden, not empty, simply not there. + await expect(body.queryByPlaceholderText('Search…')).toBeNull(); + + const high = await body.findByRole('radio', { name: /^High/ }); + await userEvent.click(high); + await expect(high).toBeChecked(); + await expect(canvas.getByRole('button', { name: 'Priority (1)' })).toBeTruthy(); + }, + render: () => { + const buttonRef = useRef(null!); + const [isOpen, setIsOpen] = useState(false); + + const filters: FilterDefinition[] = useMemo(() => [ + { + key: 'priority', + label: 'Priority', + type: 'string', + options: [ + { key: 'high', label: 'High', value: 'high', count: 4 }, + { key: 'medium', label: 'Medium', value: 'medium', count: 11 }, + { key: 'low', label: 'Low', value: 'low', count: 22 }, + ], + }, + ], []); + + const { filterValues, rangeValues, expandedFilterKey, setExpandedFilterKey, handleToggleFilter, handleClearFilter, handleRangeChange } = + useFilterState(filters); + + const activeCount = filterValues['priority']?.size ?? 0; + + return ( +
+
+

+ No Search For A Short List +

+

+ Three options fit without scrolling, so no search box is rendered - + compare with "Search appears only when the list overflows". +

+
+
+ +
+ setIsOpen(false)} + onFilterToggle={handleToggleFilter} + onFilterClear={handleClearFilter} + onRangeChange={handleRangeChange} + onExpandedFilterChange={setExpandedFilterKey} + /> +
+ ); + }, +}; From 45ee8d7f2b093c0819a7c6f90d375b6a6ed4905e Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 8 Sep 2026 13:55:02 +0200 Subject: [PATCH 4/6] Update story-count ratchet and fix stale hardcoded log numbers CheckboxListFilter.stories.tsx plus two new stories on FilterPanel bring the totals from 67/277/67 to 68/283/68 (modules/stories/docs pages). While updating the ratchet, also fixed the adapter and matrix log lines: they printed literal "277"/"67" text instead of the computed counts, so they were already silently stale before this change - now they read from the same variables the check enforces. --- Storybook/scripts/verify-storybook-indexes.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Storybook/scripts/verify-storybook-indexes.mjs b/Storybook/scripts/verify-storybook-indexes.mjs index 7d42defd..a100c52f 100644 --- a/Storybook/scripts/verify-storybook-indexes.mjs +++ b/Storybook/scripts/verify-storybook-indexes.mjs @@ -24,7 +24,7 @@ const collectTextFiles = directory => readdirSync(directory, { withFileTypes: tr return entry.isFile() && /\.(?:html|js|json)$/u.test(entry.name) ? [entryPath] : []; }); const storyFiles = collectStoryFiles(sourceRoot); -if (storyFiles.length !== 67) throw new Error(`Expected the existing 67 story modules, found ${storyFiles.length}.`); +if (storyFiles.length !== 68) throw new Error(`Expected the existing 68 story modules, found ${storyFiles.length}.`); let canonicalStoryIds; let canonicalDocsIds; @@ -39,8 +39,8 @@ for (const adapter of inventory.adapters) { const entries = Object.values(index.entries ?? {}); const storyIds = entries.filter(entry => entry.type === 'story').map(entry => entry.id).sort(); const docsIds = entries.filter(entry => entry.type === 'docs').map(entry => entry.id).sort(); - if (storyIds.length !== 277 || docsIds.length !== 67) { - throw new Error(`${adapter.metadata.id} indexed ${storyIds.length} stories and ${docsIds.length} autodocs pages; expected 277 and 67.`); + if (storyIds.length !== 283 || docsIds.length !== 68) { + throw new Error(`${adapter.metadata.id} indexed ${storyIds.length} stories and ${docsIds.length} autodocs pages; expected 283 and 68.`); } canonicalStoryIds ??= storyIds; canonicalDocsIds ??= docsIds; @@ -65,11 +65,11 @@ for (const adapter of inventory.adapters) { throw new Error(`PrimeReact 11 preview exposed a license-key environment contract in ${exposedEnvironmentContract}.`); } } - console.log(`${adapter.metadata.id}: 277 stories, 67 autodocs pages, primereact [${expectedVersions.join(', ') || 'none'}].`); + console.log(`${adapter.metadata.id}: ${storyIds.length} stories, ${docsIds.length} autodocs pages, primereact [${expectedVersions.join(', ') || 'none'}].`); } const appearances = 2; -const matrixCount = inventory.adapters.length * 277 * appearances; -console.log(`Storybook indexes verified: ${inventory.adapters.length} previews × 277 stable stories × ${appearances} appearances = ${matrixCount} story/appearance cases.`); +const matrixCount = inventory.adapters.length * canonicalStoryIds.length * appearances; +console.log(`Storybook indexes verified: ${inventory.adapters.length} previews × ${canonicalStoryIds.length} stable stories × ${appearances} appearances = ${matrixCount} story/appearance cases.`); console.log('Story exclusions: none. Every indexed story is included in light, dark, and axe execution.'); console.log(`Renderer exclusions: ${inventory.exclusions.map(item => `${item.id} (${item.reason})`).join(', ') || 'none'}; private adapters such as Plain are never composed.`); From 5f03aa511debae33d01d788168caeee81aa31f06 Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 8 Sep 2026 14:25:06 +0200 Subject: [PATCH 5/6] Render the overflow mirror's text via CSS content, not a text node The off-screen mirror list used to decide whether a search box is needed duplicated each option's real label/count as literal DOM text, so a search that filters the real list down still leaves the mirror's matching text discoverable by any text-content query (e.g. Testing Library's getByText/queryByText, which do not consider aria-hidden). CSS generated content occupies the same layout space for measurement purposes without ever becoming DOM text, so the mirror can no longer be mistaken for a real, currently-visible row. --- Source/Filter/CheckboxListFilter.tsx | 15 +++++++++++---- Source/Filter/FilterPanel.css | 6 ++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Source/Filter/CheckboxListFilter.tsx b/Source/Filter/CheckboxListFilter.tsx index 162519ca..897d5c7a 100644 --- a/Source/Filter/CheckboxListFilter.tsx +++ b/Source/Filter/CheckboxListFilter.tsx @@ -153,10 +153,17 @@ export function CheckboxListFilter({
  • ))} diff --git a/Source/Filter/FilterPanel.css b/Source/Filter/FilterPanel.css index 44db9d0c..ee498903 100644 --- a/Source/Filter/FilterPanel.css +++ b/Source/Filter/FilterPanel.css @@ -169,6 +169,12 @@ overflow: visible; } +/* Rendered via `content: attr(...)` rather than a DOM text node - see CheckboxListFilter.tsx - + so a mirror row's label/count can never be found by a text-content query. */ +.pv-option-mirror-label::before { + content: attr(data-label); +} + .pv-option-list-search { position: sticky; top: 0; From 0143450a0593f9d522cc62357a34b8078259fed6 Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 8 Sep 2026 14:25:06 +0200 Subject: [PATCH 6/6] Scope the Mixed filter types story to the panel's own search box Five options is enough for Department's own option list to overflow its fixed-height box, so it now legitimately grows a search box of its own - inheriting this panel's placeholder text by design (see for_FilterPanel/when_a_filter_group_has_no_own_search_placeholder.tsx). The story's play function queried by that placeholder text globally and started matching both boxes. Scope to .pv-search, the panel's own top-level search container, rather than either per-group one. --- Source/Filter/FilterPanel.stories.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Source/Filter/FilterPanel.stories.tsx b/Source/Filter/FilterPanel.stories.tsx index daab55a9..8ae43b1d 100644 --- a/Source/Filter/FilterPanel.stories.tsx +++ b/Source/Filter/FilterPanel.stories.tsx @@ -570,7 +570,14 @@ export const MixedFilters: Story = { 'Filters', 'Department', ); - const search = body.getByPlaceholderText('Search filters…'); + // Department's own option list also has room to show a search box now (five options + // is enough to overflow the list's fixed-height box), and it shares this panel's + // placeholder text by design (see for_FilterPanel/when_a_filter_group_has_no_own_search_placeholder.tsx) + // - so scope to .pv-search, the panel's own top search, rather than matching either box. + const panelSearch = within( + document.body.querySelector('.pv-search') as HTMLElement, + ); + const search = panelSearch.getByPlaceholderText('Search filters…'); await userEvent.type(search, 'department'); await expect(search).toHaveValue('department'); const engineering = await body.findByRole('checkbox', { name: /^Engineering/ });