From 273bd8bd2a2c51114f15ae797b3a27309c9fde37 Mon Sep 17 00:00:00 2001 From: Simeon Lee <15081451+simeonlee@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:05:14 -0500 Subject: [PATCH 1/3] Add virtualization support for Combobox and ButtonSelect --- pnpm-lock.yaml | 20 +++ ui/app/components/ui/combobox/Combobox.tsx | 115 +++++++++++++++++- .../ui/combobox/ComboboxMenuItems.tsx | 74 +++++++---- ui/app/components/ui/select/ButtonSelect.tsx | 94 +++++++++++++- .../ui/virtualized-command-items.tsx | 87 +++++++++++++ ui/package.json | 1 + 6 files changed, 366 insertions(+), 25 deletions(-) create mode 100644 ui/app/components/ui/virtualized-command-items.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a80caf42d2..48f04eff23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@tanstack/react-virtual': + specifier: ^3.13.18 + version: 3.13.18(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@uiw/codemirror-theme-github': specifier: ^4.24.0 version: 4.24.0(@codemirror/language@6.11.2)(@codemirror/state@6.5.2)(@codemirror/view@6.38.0) @@ -2237,10 +2240,19 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.18': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -6853,8 +6865,16 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + '@tanstack/react-virtual@3.13.18(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@tanstack/virtual-core': 3.13.18 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.13.18': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 diff --git a/ui/app/components/ui/combobox/Combobox.tsx b/ui/app/components/ui/combobox/Combobox.tsx index 5de8d637b4..ebe637819c 100644 --- a/ui/app/components/ui/combobox/Combobox.tsx +++ b/ui/app/components/ui/combobox/Combobox.tsx @@ -3,13 +3,16 @@ import { PopoverAnchor, PopoverContent, } from "~/components/ui/popover"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ComboboxInput } from "./ComboboxInput"; import { ComboboxContent } from "./ComboboxContent"; import { ComboboxHint } from "./ComboboxHint"; import { ComboboxMenuItems } from "./ComboboxMenuItems"; import { useCombobox } from "./use-combobox"; +/** Default threshold for enabling virtualization */ +const DEFAULT_VIRTUALIZE_THRESHOLD = 100; + export type ComboboxItem = string | { value: string; label: string }; export type NormalizedComboboxItem = { value: string; label: string }; @@ -40,6 +43,12 @@ type ComboboxProps = { loadingMessage?: string; error?: boolean; errorMessage?: string; + /** + * Number of items at which virtualization is enabled. + * Set to 0 to always virtualize, or Infinity to never virtualize. + * Default: 100 + */ + virtualizeThreshold?: number; }; export function Combobox({ @@ -61,6 +70,7 @@ export function Combobox({ loadingMessage = "Loading...", error = false, errorMessage = "An error occurred.", + virtualizeThreshold = DEFAULT_VIRTUALIZE_THRESHOLD, }: ComboboxProps) { const { open, @@ -68,12 +78,15 @@ export function Combobox({ commandRef, getInputValue, closeDropdown, - handleKeyDown, + handleKeyDown: baseHandleKeyDown, handleInputChange, handleBlur, handleClick, } = useCombobox(); + // Track highlighted index for virtualized keyboard navigation + const [highlightedIndex, setHighlightedIndex] = useState(0); + // Normalize items to { value, label } format const normalizedItems = useMemo(() => items.map(normalizeItem), [items]); @@ -93,6 +106,24 @@ export function Combobox({ ); }, [normalizedItems, searchValue]); + const shouldVirtualize = filteredItems.length >= virtualizeThreshold; + + // Reset highlighted index when dropdown opens or filtered items change + useEffect(() => { + if (open) { + setHighlightedIndex((prev) => { + if (filteredItems.length === 0) return 0; + // Clamp to valid range if items were filtered + return Math.min(prev, filteredItems.length - 1); + }); + } + }, [open, filteredItems.length]); + + // Reset to first item when search changes + useEffect(() => { + setHighlightedIndex(0); + }, [searchValue]); + const handleSelectItem = useCallback( (value: string, isNew: boolean) => { onSelect(value, isNew); @@ -101,6 +132,84 @@ export function Combobox({ [onSelect, closeDropdown], ); + // Custom keyboard handler for virtualized mode + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!shouldVirtualize) { + // Non-virtualized: delegate to cmdk + baseHandleKeyDown(e); + return; + } + + // Virtualized mode: handle navigation ourselves + if (e.key === "Escape") { + closeDropdown(); + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredItems.length - 1 ? prev + 1 : prev, + ); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + return; + } + + if (e.key === "Home") { + e.preventDefault(); + setHighlightedIndex(0); + return; + } + + if (e.key === "End") { + e.preventDefault(); + setHighlightedIndex(Math.max(0, filteredItems.length - 1)); + return; + } + + if (e.key === "PageDown") { + e.preventDefault(); + // Jump ~8 items (one viewport) + setHighlightedIndex((prev) => + Math.min(prev + 8, filteredItems.length - 1), + ); + return; + } + + if (e.key === "PageUp") { + e.preventDefault(); + setHighlightedIndex((prev) => Math.max(prev - 8, 0)); + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + const item = filteredItems[highlightedIndex]; + if (item) { + handleSelectItem(item.value, false); + } + return; + } + + // For other keys, use base handler + baseHandleKeyDown(e); + }, + [ + shouldVirtualize, + baseHandleKeyDown, + closeDropdown, + filteredItems, + highlightedIndex, + handleSelectItem, + ], + ); + const showCreateOption = allowCreation && Boolean(searchValue.trim()) && @@ -170,6 +279,8 @@ export function Combobox({ getPrefix={getPrefix} getSuffix={getSuffix} getItemDataAttributes={getItemDataAttributes} + virtualize={shouldVirtualize} + highlightedIndex={highlightedIndex} /> )} diff --git a/ui/app/components/ui/combobox/ComboboxMenuItems.tsx b/ui/app/components/ui/combobox/ComboboxMenuItems.tsx index 4534bf8137..da8c9a4628 100644 --- a/ui/app/components/ui/combobox/ComboboxMenuItems.tsx +++ b/ui/app/components/ui/combobox/ComboboxMenuItems.tsx @@ -1,6 +1,7 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { CommandGroup, CommandItem } from "~/components/ui/command"; import { normalizeItem, type ComboboxItem } from "./Combobox"; +import { VirtualizedCommandItems } from "~/components/ui/virtualized-command-items"; type ComboboxMenuItemsProps = { items: ComboboxItem[]; @@ -13,6 +14,10 @@ type ComboboxMenuItemsProps = { getPrefix?: (value: string | null, isSelected: boolean) => React.ReactNode; getSuffix?: (value: string | null) => React.ReactNode; getItemDataAttributes?: (value: string) => Record; + /** Enable virtualization for large lists */ + virtualize?: boolean; + /** Currently highlighted index for virtualized keyboard navigation */ + highlightedIndex?: number; }; export function ComboboxMenuItems({ @@ -26,10 +31,48 @@ export function ComboboxMenuItems({ getPrefix, getSuffix, getItemDataAttributes, + virtualize = false, + highlightedIndex, }: ComboboxMenuItemsProps) { // Normalize items to { value, label } format const normalizedItems = useMemo(() => items.map(normalizeItem), [items]); + const renderItem = useCallback( + (item: { value: string; label: string }, index: number) => { + const isSelected = selectedValue === item.value; + const isHighlighted = virtualize && index === highlightedIndex; + return ( + onSelectItem(item.value, false)} + className="group flex w-full items-center gap-2" + // In virtualized mode, manually set data-selected for highlight styling + data-selected={isHighlighted || undefined} + aria-selected={isHighlighted || isSelected} + {...getItemDataAttributes?.(item.value)} + > +
+ {getPrefix?.(item.value, isSelected)} + + {item.label} + +
+ {getSuffix?.(item.value)} +
+ ); + }, + [ + selectedValue, + virtualize, + highlightedIndex, + onSelectItem, + getItemDataAttributes, + getPrefix, + getSuffix, + ], + ); + return ( <> {showCreateOption && ( @@ -48,26 +91,15 @@ export function ComboboxMenuItems({ )} {normalizedItems.length > 0 && ( - {normalizedItems.map((item) => { - const isSelected = selectedValue === item.value; - return ( - onSelectItem(item.value, false)} - className="group flex w-full items-center gap-2" - {...getItemDataAttributes?.(item.value)} - > -
- {getPrefix?.(item.value, isSelected)} - - {item.label} - -
- {getSuffix?.(item.value)} -
- ); - })} + {virtualize ? ( + + ) : ( + normalizedItems.map((item, index) => renderItem(item, index)) + )}
)} diff --git a/ui/app/components/ui/select/ButtonSelect.tsx b/ui/app/components/ui/select/ButtonSelect.tsx index 906d0df03e..15e22c80b9 100644 --- a/ui/app/components/ui/select/ButtonSelect.tsx +++ b/ui/app/components/ui/select/ButtonSelect.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Popover, PopoverContent, @@ -64,8 +64,17 @@ export interface ButtonSelectProps { align?: "start" | "center" | "end"; /** Additional className for the popover menu */ menuClassName?: string; + /** + * Number of items at which virtualization is enabled. + * Set to 0 to always virtualize, or Infinity to never virtualize. + * Default: 100 + */ + virtualizeThreshold?: number; } +/** Default threshold for enabling virtualization */ +const DEFAULT_VIRTUALIZE_THRESHOLD = 100; + export function ButtonSelect({ items, onSelect, @@ -88,9 +97,11 @@ export function ButtonSelect({ creatable = false, align = "start", menuClassName, + virtualizeThreshold = DEFAULT_VIRTUALIZE_THRESHOLD, }: ButtonSelectProps) { const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); const filteredItems = useMemo(() => { if (!searchable || !searchValue.trim()) { @@ -100,6 +111,23 @@ export function ButtonSelect({ return items.filter((item) => item.toLowerCase().includes(search)); }, [items, searchValue, searchable]); + const shouldVirtualize = filteredItems.length >= virtualizeThreshold; + + // Reset highlighted index when dropdown opens or filtered items change + useEffect(() => { + if (open) { + setHighlightedIndex((prev) => { + if (filteredItems.length === 0) return 0; + return Math.min(prev, filteredItems.length - 1); + }); + } + }, [open, filteredItems.length]); + + // Reset to first item when search changes + useEffect(() => { + setHighlightedIndex(0); + }, [searchValue]); + const showCreateOption = searchable && creatable && @@ -119,6 +147,63 @@ export function ButtonSelect({ [onSelect], ); + // Keyboard handler for virtualized mode + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!shouldVirtualize) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredItems.length - 1 ? prev + 1 : prev, + ); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + return; + } + + if (e.key === "Home") { + e.preventDefault(); + setHighlightedIndex(0); + return; + } + + if (e.key === "End") { + e.preventDefault(); + setHighlightedIndex(Math.max(0, filteredItems.length - 1)); + return; + } + + if (e.key === "PageDown") { + e.preventDefault(); + setHighlightedIndex((prev) => + Math.min(prev + 8, filteredItems.length - 1), + ); + return; + } + + if (e.key === "PageUp") { + e.preventDefault(); + setHighlightedIndex((prev) => Math.max(prev - 8, 0)); + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + const item = filteredItems[highlightedIndex]; + if (item) { + handleSelectItem(item, false); + } + return; + } + }, + [shouldVirtualize, filteredItems, highlightedIndex, handleSelectItem], + ); + const triggerContent = typeof trigger === "function" ? trigger({ open }) : trigger; @@ -149,7 +234,10 @@ export function ButtonSelect({ )} align={align} > - + {searchable && ( )} diff --git a/ui/app/components/ui/virtualized-command-items.tsx b/ui/app/components/ui/virtualized-command-items.tsx new file mode 100644 index 0000000000..596c4387ee --- /dev/null +++ b/ui/app/components/ui/virtualized-command-items.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +/** + * CommandItem height: py-1.5 (12px) + text line height (~24px) = 36px + * This matches the CommandItem className in command.tsx + */ +const COMMAND_ITEM_HEIGHT = 36; + +/** Extra items rendered above/below viewport for smooth scrolling */ +const OVERSCAN = 8; + +/** Maximum height of the virtualized list */ +const MAX_HEIGHT = 300; + +interface VirtualizedCommandItemsProps { + items: T[]; + renderItem: (item: T, index: number) => React.ReactNode; + /** Currently highlighted index for keyboard navigation */ + highlightedIndex?: number; + className?: string; +} + +/** + * Renders command items using virtualization. + * Only visible items (plus overscan buffer) exist in the DOM. + * + * Requires parent Command to have shouldFilter={false}. + */ +function VirtualizedCommandItems({ + items, + renderItem, + highlightedIndex, + className, +}: VirtualizedCommandItemsProps) { + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => COMMAND_ITEM_HEIGHT, + overscan: OVERSCAN, + }); + + React.useEffect(() => { + if (highlightedIndex !== undefined && highlightedIndex >= 0) { + virtualizer.scrollToIndex(highlightedIndex, { align: "auto" }); + } + }, [highlightedIndex, virtualizer]); + + const virtualItems = virtualizer.getVirtualItems(); + + if (items.length === 0) { + return null; + } + + return ( +
+
+ {virtualItems.map((virtualRow) => ( +
+ {renderItem(items[virtualRow.index], virtualRow.index)} +
+ ))} +
+
+ ); +} + +export { VirtualizedCommandItems, COMMAND_ITEM_HEIGHT }; diff --git a/ui/package.json b/ui/package.json index 0948b8603a..4434f35de2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -35,6 +35,7 @@ "@tailwindcss/vite": "^4.1.15", "@tanstack/react-query": "^5.83.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.18", "@uiw/codemirror-theme-github": "^4.24.0", "@uiw/react-codemirror": "^4.24.0", "@vitejs/plugin-react": "^5.0.4", From 4d496cbf79ae8d20c5df324ddbdfe90ccd0bbfbe Mon Sep 17 00:00:00 2001 From: Simeon Lee <15081451+simeonlee@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:06:53 -0500 Subject: [PATCH 2/3] Add storybook stories for virtualization --- .../ui/combobox/Combobox.stories.tsx | 362 ++++++++++++++++++ .../ui/select/ButtonSelect.stories.tsx | 120 ++++++ 2 files changed, 482 insertions(+) create mode 100644 ui/app/components/ui/combobox/Combobox.stories.tsx diff --git a/ui/app/components/ui/combobox/Combobox.stories.tsx b/ui/app/components/ui/combobox/Combobox.stories.tsx new file mode 100644 index 0000000000..7ae9986fa8 --- /dev/null +++ b/ui/app/components/ui/combobox/Combobox.stories.tsx @@ -0,0 +1,362 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Combobox } from "./Combobox"; +import { useArgs } from "storybook/preview-api"; +import { Box } from "lucide-react"; + +function generateItems(count: number): string[] { + return Array.from({ length: count }, (_, i) => `item-${i + 1}`); +} + +const meta: Meta = { + title: "UI/Combobox", + component: Combobox, + argTypes: { + virtualizeThreshold: { + control: { type: "number", min: 0, max: 1000 }, + description: "Number of items at which virtualization is enabled", + }, + disabled: { + control: "boolean", + description: "Disable the combobox", + }, + allowCreation: { + control: "boolean", + description: "Allow creating new items", + }, + loading: { + control: "boolean", + description: "Show loading state", + }, + error: { + control: "boolean", + description: "Show error state", + }, + }, + parameters: { + controls: { + exclude: [ + "onSelect", + "selected", + "items", + "getPrefix", + "getSuffix", + "getItemDataAttributes", + ], + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: "Select item", + emptyMessage: "No items found", + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +export const WithIcon: Story = { + args: { + placeholder: "Select item", + emptyMessage: "No items found", + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+ updateArgs({ selected: item })} + getPrefix={() => } + /> +
+ ); + }, +}; + +/** + * With 100+ items, virtualization kicks in automatically. + * Only visible items are rendered in the DOM for better performance. + */ +export const Virtualized: Story = { + args: { + placeholder: "Search 500 items...", + emptyMessage: "No items found", + virtualizeThreshold: 100, + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ This combobox has 500 items. Virtualization renders only visible items + for smooth scrolling. +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +/** + * Force virtualization even with small lists by setting threshold to 0. + */ +export const ForceVirtualized: Story = { + args: { + placeholder: "Virtualized (20 items)", + emptyMessage: "No items found", + virtualizeThreshold: 0, + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ Virtualization forced on with only 20 items (threshold=0). +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +/** + * Disable virtualization entirely by setting threshold to Infinity. + */ +export const NoVirtualization: Story = { + args: { + placeholder: "No virtualization (200 items)", + emptyMessage: "No items found", + virtualizeThreshold: Infinity, + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ 200 items rendered without virtualization (threshold=Infinity). May be + slower. +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +/** + * Stress test with 1000 items to verify virtualization performance. + */ +export const StressTest: Story = { + args: { + placeholder: "Search 1000 items...", + emptyMessage: "No items found", + virtualizeThreshold: 100, + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ Stress test: 1000 items with virtualization. Should scroll smoothly. +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +/** + * Demo keyboard navigation in virtualized mode. + * - Arrow Up/Down: Move highlight + * - Home/End: Jump to first/last + * - PageUp/PageDown: Jump ~8 items + * - Enter: Select highlighted item + * - Escape: Close dropdown + */ +export const KeyboardNavigation: Story = { + args: { + placeholder: "Click then use arrow keys...", + emptyMessage: "No items found", + virtualizeThreshold: 100, + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ Keyboard shortcuts: +
+ Arrow Up/Down: Move highlight +
+ Home/End: Jump to start/end +
+ PageUp/PageDown: Jump 8 items +
+ Enter: Select +
+ Escape: Close +

+ updateArgs({ selected: item })} + /> + {selected && ( +

+ Selected: {selected} +

+ )} +
+ ); + }, +}; + +/** + * Demo filtering behavior with virtualization. + * Type to filter, selection persists through filter changes. + */ +export const FilteringDemo: Story = { + args: { + placeholder: "Try typing 'item-5' or 'apple'...", + emptyMessage: "No items found", + virtualizeThreshold: 50, + }, + render: function Render(args) { + const items = [ + ...generateItems(100), + "apple", + "banana", + "cherry", + "date", + "elderberry", + ]; + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ Type to filter. Try "item-5" or "apple". Highlight + resets to first match when filtering. +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +/** + * Edge case: transition between virtualized and non-virtualized + * as user filters items below/above threshold. + */ +export const ThresholdTransition: Story = { + args: { + placeholder: "Type to filter below 100...", + emptyMessage: "No items found", + virtualizeThreshold: 100, + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ 150 items with threshold=100. Type to filter below 100 items and + observe transition from virtualized to non-virtualized rendering. +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +/** + * Virtualized combobox with creation support. + */ +export const VirtualizedWithCreation: Story = { + args: { + placeholder: "Search or create...", + emptyMessage: "No items found", + virtualizeThreshold: 100, + allowCreation: true, + createHint: "Type to create a new item", + createHeading: "Create new", + }, + render: function Render(args) { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ 500 items with creation support. Type a name that does not exist to + see the create option. +

+ updateArgs({ selected: item })} + /> + {selected && ( +

+ Selected: {selected} +

+ )} +
+ ); + }, +}; diff --git a/ui/app/components/ui/select/ButtonSelect.stories.tsx b/ui/app/components/ui/select/ButtonSelect.stories.tsx index ea3eaff570..6050d4659c 100644 --- a/ui/app/components/ui/select/ButtonSelect.stories.tsx +++ b/ui/app/components/ui/select/ButtonSelect.stories.tsx @@ -13,6 +13,8 @@ const mockItems = [ ]; const manyItems = Array.from({ length: 100 }, (_, i) => `variant-${i + 1}`); +const virtualizedItems = Array.from({ length: 500 }, (_, i) => `item-${i + 1}`); +const stressTestItems = Array.from({ length: 1000 }, (_, i) => `item-${i + 1}`); const meta: Meta = { title: "UI/ButtonSelect", @@ -239,3 +241,121 @@ export const NonSearchable: Story = { ); }, }; + +/** + * With 100+ items, virtualization kicks in automatically. + * Only visible items are rendered in the DOM for better performance. + */ +export const Virtualized: Story = { + render: function Render() { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ 500 items with virtualization (threshold=100). Should scroll smoothly. +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +/** + * Stress test with 1000 items to verify virtualization performance. + */ +export const VirtualizedStressTest: Story = { + render: function Render() { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ Stress test: 1000 items with virtualization. +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; + +/** + * Keyboard navigation in virtualized mode. + * - Arrow Up/Down: Move highlight + * - Home/End: Jump to first/last + * - PageUp/PageDown: Jump ~8 items + * - Enter: Select highlighted item + */ +export const VirtualizedKeyboardNav: Story = { + render: function Render() { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ Keyboard shortcuts: Arrow Up/Down, Home/End, + PageUp/PageDown, Enter +

+ updateArgs({ selected: item })} + /> + {selected && ( +

+ Selected: {selected} +

+ )} +
+ ); + }, +}; + +/** + * Virtualized with creation support. + */ +export const VirtualizedWithCreation: Story = { + render: function Render() { + const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); + + return ( +
+

+ 500 items with creation support. Type a name that does not exist. +

+ updateArgs({ selected: item })} + /> +
+ ); + }, +}; From be29ca5ffe257a633ac4dc2497f5f9ce20ea7e8e Mon Sep 17 00:00:00 2001 From: Simeon Lee <15081451+simeonlee@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:10:29 -0500 Subject: [PATCH 3/3] Remove description text from storybook renders --- .../ui/combobox/Combobox.stories.tsx | 41 +--------- .../ui/select/ButtonSelect.stories.tsx | 79 +++++++------------ 2 files changed, 31 insertions(+), 89 deletions(-) diff --git a/ui/app/components/ui/combobox/Combobox.stories.tsx b/ui/app/components/ui/combobox/Combobox.stories.tsx index 7ae9986fa8..acd46b5a96 100644 --- a/ui/app/components/ui/combobox/Combobox.stories.tsx +++ b/ui/app/components/ui/combobox/Combobox.stories.tsx @@ -108,10 +108,6 @@ export const Virtualized: Story = { return (
-

- This combobox has 500 items. Virtualization renders only visible items - for smooth scrolling. -

-

- Virtualization forced on with only 20 items (threshold=0). -

-

- 200 items rendered without virtualization (threshold=Infinity). May be - slower. -

-

- Stress test: 1000 items with virtualization. Should scroll smoothly. -

-

- Keyboard shortcuts: -
- Arrow Up/Down: Move highlight -
- Home/End: Jump to start/end -
- PageUp/PageDown: Jump 8 items -
- Enter: Select -
- Escape: Close -

-

- Type to filter. Try "item-5" or "apple". Highlight - resets to first match when filtering. -

-

- 150 items with threshold=100. Type to filter below 100 items and - observe transition from virtualized to non-virtualized rendering. -

-

- 500 items with creation support. Type a name that does not exist to - see the create option. -

(); return ( -
-

- 500 items with virtualization (threshold=100). Should scroll smoothly. -

- updateArgs({ selected: item })} - /> -
+ updateArgs({ selected: item })} + /> ); }, }; @@ -277,20 +272,15 @@ export const VirtualizedStressTest: Story = { const [{ selected }, updateArgs] = useArgs<{ selected?: string }>(); return ( -
-

- Stress test: 1000 items with virtualization. -

- updateArgs({ selected: item })} - /> -
+ updateArgs({ selected: item })} + /> ); }, }; @@ -308,10 +298,6 @@ export const VirtualizedKeyboardNav: Story = { return (
-

- Keyboard shortcuts: Arrow Up/Down, Home/End, - PageUp/PageDown, Enter -

(); return ( -
-

- 500 items with creation support. Type a name that does not exist. -

- updateArgs({ selected: item })} - /> -
+ updateArgs({ selected: item })} + /> ); }, };