diff --git a/docs/src/app/(docs)/react/components/autocomplete/types.md b/docs/src/app/(docs)/react/components/autocomplete/types.md index 12c4077dda9..28a83068b0e 100644 --- a/docs/src/app/(docs)/react/components/autocomplete/types.md +++ b/docs/src/app/(docs)/react/components/autocomplete/types.md @@ -40,7 +40,7 @@ Doesn't render its own HTML element. | onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the popup is opened or closed. | | openOnInputClick | `boolean` | `false` | Whether the popup opens when clicking the input. | | submitOnItemClick | `boolean` | `false` | Whether clicking an item should submit the autocomplete's owning form. By default, clicking an item via a pointer or Enter key does not submit the owning form. Useful when the autocomplete is used as a single-field form search input. | -| virtualized | `boolean` | `false` | Whether the items are being externally virtualized. | +| virtualized | `boolean` | `false` | Whether the items are being virtualized by an external, third-party virtualizer. | | disabled | `boolean` | `false` | Whether the component should ignore user interaction. | | readOnly | `boolean` | `false` | Whether the user should be unable to choose a different option from the popup. | | required | `boolean` | `false` | Whether the user must choose a value before submitting a form. | diff --git a/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/css-modules/index.module.css b/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/css-modules/index.module.css new file mode 100644 index 00000000000..41a49016599 --- /dev/null +++ b/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/css-modules/index.module.css @@ -0,0 +1,150 @@ +.Input { + box-sizing: border-box; + padding: 0 0.5rem; + margin: 0; + border-radius: 0; + border: 1px solid oklch(14.5% 0 0deg); + width: 16rem; + height: 2rem; + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 400; + background-color: white; + color: oklch(14.5% 0 0deg); + outline: none; + + @media (any-pointer: coarse) { + font-size: 1rem; + line-height: 1.5rem; + } + + @media (prefers-color-scheme: dark) { + border: 1px solid white; + background-color: oklch(14.5% 0 0deg); + color: white; + } + + &:focus { + outline: 2px solid oklch(14.5% 0 0deg); + outline-offset: -1px; + + @media (prefers-color-scheme: dark) { + outline-color: white; + } + } +} + +.Label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 700; + color: oklch(14.5% 0 0deg); + + @media (prefers-color-scheme: dark) { + color: white; + } +} + +.Positioner { + outline: 0; +} + +.Popup { + box-sizing: border-box; + background-color: white; + color: oklch(14.5% 0 0deg); + width: var(--anchor-width); + max-width: var(--available-width); + border: 1px solid oklch(14.5% 0 0deg); + box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%); + + @media (prefers-color-scheme: dark) { + background-color: oklch(14.5% 0 0deg); + color: white; + border: 1px solid white; + box-shadow: none; + } +} + +.Scroller { + box-sizing: border-box; + height: min(22.5rem, var(--total-size)); + max-height: var(--available-height); + overflow: auto; + overscroll-behavior: contain; + scroll-padding-block: 0.25rem; +} + +.VirtualizedPlaceholder { + width: 100%; + position: relative; +} + +.List { + box-sizing: border-box; + padding: 0; +} + +.Item { + box-sizing: border-box; + outline: 0; + cursor: default; + -webkit-user-select: none; + user-select: none; + padding-block: 0.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + display: grid; + gap: 0.5rem; + align-items: center; + grid-template-columns: 1rem 1fr; + font-size: 0.875rem; + line-height: 1rem; + + &[data-highlighted] { + z-index: 0; + position: relative; + color: white; + + @media (prefers-color-scheme: dark) { + color: oklch(14.5% 0 0deg); + } + } + + &[data-highlighted]::before { + content: ''; + z-index: -1; + position: absolute; + inset-block: 0; + inset-inline: 0; + background-color: oklch(14.5% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: white; + } + } +} + +.ItemText { + grid-column-start: 2; +} + +.ItemIndicator { + grid-column-start: 1; +} + +.Empty { + box-sizing: border-box; + font-size: 0.875rem; + line-height: 1rem; + color: oklch(55.6% 0 0deg); + padding: 0.75rem 0.5rem; + + @media (prefers-color-scheme: dark) { + color: oklch(70.8% 0 0deg); + } +} diff --git a/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/css-modules/index.tsx b/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/css-modules/index.tsx new file mode 100644 index 00000000000..9c4985b28b9 --- /dev/null +++ b/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/css-modules/index.tsx @@ -0,0 +1,171 @@ +'use client'; +import * as React from 'react'; +import { Combobox } from '@base-ui/react/combobox'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import styles from './index.module.css'; + +export default function ExampleVirtualizedCombobox() { + const virtualizerRef = React.useRef(null); + + return ( + { + const virtualizer = virtualizerRef.current; + + if (!item || !virtualizer) { + return; + } + + const isStart = index === 0; + const isEnd = index === virtualizer.options.count - 1; + const shouldScroll = reason === 'none' || (reason === 'keyboard' && (isStart || isEnd)); + + if (shouldScroll) { + queueMicrotask(() => { + virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' }); + }); + } + }} + > + + + + + + +
No items found.
+
+ + + +
+
+
+
+ ); +} + +function VirtualizedList({ + virtualizerRef, +}: { + virtualizerRef: React.RefObject; +}) { + const filteredItems = Combobox.useFilteredItems(); + const scrollElementRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: filteredItems.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: () => 32, + overscan: 20, + paddingStart: 4, + paddingEnd: 4, + scrollPaddingEnd: 4, + scrollPaddingStart: 4, + }); + + React.useImperativeHandle(virtualizerRef, () => virtualizer); + + const handleScrollElementRef = React.useCallback( + (element: HTMLDivElement | null) => { + scrollElementRef.current = element; + if (element) { + virtualizer.measure(); + } + }, + [virtualizer], + ); + + const totalSize = virtualizer.getTotalSize(); + + if (filteredItems.length === 0) { + return null; + } + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = filteredItems[virtualItem.index]; + if (!item) { + return null; + } + + return ( + + + + + {item.name} + + ); + })} +
+
+ ); +} + +function CheckIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +interface DemoItem { + id: string; + name: string; +} + +function getItemLabel(item: DemoItem | null) { + return item ? item.name : ''; +} + +const virtualizedItems: DemoItem[] = Array.from({ length: 10000 }, (_, index) => { + const id = String(index + 1); + const indexLabel = id.padStart(4, '0'); + return { id, name: `Item ${indexLabel}` }; +}); + +type Virtualizer = ReturnType>; diff --git a/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/index.ts b/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/index.ts new file mode 100644 index 00000000000..2e7034ce0b1 --- /dev/null +++ b/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/index.ts @@ -0,0 +1,8 @@ +import { createDemoWithVariants } from 'docs/src/utils/createDemo'; +import CssModules from './css-modules'; +import Tailwind from './tailwind'; + +export const DemoComboboxVirtualizedTanStack = createDemoWithVariants(import.meta.url, { + CssModules, + Tailwind, +}); diff --git a/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/tailwind/index.tsx b/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/tailwind/index.tsx new file mode 100644 index 00000000000..06c30c49c48 --- /dev/null +++ b/docs/src/app/(docs)/react/components/combobox/demos/virtualized-tanstack/tailwind/index.tsx @@ -0,0 +1,168 @@ +'use client'; +import * as React from 'react'; +import { Combobox } from '@base-ui/react/combobox'; +import { useVirtualizer } from '@tanstack/react-virtual'; + +export default function ExampleVirtualizedCombobox() { + const virtualizerRef = React.useRef(null); + + return ( + { + const virtualizer = virtualizerRef.current; + + if (!item || !virtualizer) { + return; + } + + const isStart = index === 0; + const isEnd = index === virtualizer.options.count - 1; + const shouldScroll = reason === 'none' || (reason === 'keyboard' && (isStart || isEnd)); + + if (shouldScroll) { + queueMicrotask(() => { + virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' }); + }); + } + }} + > + + + + + + +
+ No items found. +
+
+ + + +
+
+
+
+ ); +} + +function VirtualizedList({ + virtualizerRef, +}: { + virtualizerRef: React.RefObject; +}) { + const filteredItems = Combobox.useFilteredItems(); + const scrollElementRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: filteredItems.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: () => 32, + overscan: 20, + paddingStart: 4, + paddingEnd: 4, + scrollPaddingEnd: 4, + scrollPaddingStart: 4, + }); + + React.useImperativeHandle(virtualizerRef, () => virtualizer); + + const handleScrollElementRef = React.useCallback( + (element: HTMLDivElement | null) => { + scrollElementRef.current = element; + if (element) { + virtualizer.measure(); + } + }, + [virtualizer], + ); + + const totalSize = virtualizer.getTotalSize(); + + if (filteredItems.length === 0) { + return null; + } + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = filteredItems[virtualItem.index]; + if (!item) { + return null; + } + + return ( + + + + + {item.name} + + ); + })} +
+
+ ); +} + +function CheckIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +interface DemoItem { + id: string; + name: string; +} + +function getItemLabel(item: DemoItem | null) { + return item ? item.name : ''; +} + +const virtualizedItems: DemoItem[] = Array.from({ length: 10000 }, (_, index) => { + const id = String(index + 1); + const indexLabel = id.padStart(4, '0'); + return { id, name: `Item ${indexLabel}` }; +}); + +type Virtualizer = ReturnType>; diff --git a/docs/src/app/(docs)/react/components/combobox/demos/virtualized/css-modules/index.module.css b/docs/src/app/(docs)/react/components/combobox/demos/virtualized/css-modules/index.module.css index 41a49016599..3aa013df767 100644 --- a/docs/src/app/(docs)/react/components/combobox/demos/virtualized/css-modules/index.module.css +++ b/docs/src/app/(docs)/react/components/combobox/demos/virtualized/css-modules/index.module.css @@ -76,12 +76,12 @@ max-height: var(--available-height); overflow: auto; overscroll-behavior: contain; + padding-block: 0.25rem; scroll-padding-block: 0.25rem; -} -.VirtualizedPlaceholder { - width: 100%; - position: relative; + &[data-empty] { + padding-block: 0; + } } .List { diff --git a/docs/src/app/(docs)/react/components/combobox/demos/virtualized/css-modules/index.tsx b/docs/src/app/(docs)/react/components/combobox/demos/virtualized/css-modules/index.tsx index e0aab071f7a..aee2c0e96ae 100644 --- a/docs/src/app/(docs)/react/components/combobox/demos/virtualized/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/combobox/demos/virtualized/css-modules/index.tsx @@ -1,35 +1,12 @@ 'use client'; import * as React from 'react'; import { Combobox } from '@base-ui/react/combobox'; -import { useVirtualizer } from '@tanstack/react-virtual'; +import { ListVirtualizer } from '@base-ui/react/list-virtualizer'; import styles from './index.module.css'; export default function ExampleVirtualizedCombobox() { - const virtualizerRef = React.useRef(null); - return ( - { - const virtualizer = virtualizerRef.current; - - if (!item || !virtualizer) { - return; - } - - const isStart = index === 0; - const isEnd = index === virtualizer.options.count - 1; - const shouldScroll = reason === 'none' || (reason === 'keyboard' && (isStart || isEnd)); - - if (shouldScroll) { - queueMicrotask(() => { - virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' }); - }); - } - }} - > +