Skip to content

Commit

Permalink
feat: Select vistual list
Browse files Browse the repository at this point in the history
  • Loading branch information
sergiocarracedo committed Mar 4, 2025
1 parent 4ad8477 commit c63b27d
Show file tree
Hide file tree
Showing 14 changed files with 489 additions and 267 deletions.
42 changes: 36 additions & 6 deletions lib/experimental/Forms/Fields/Select/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ const meta: Meta = {
a11y: {
skipCi: true,
},
docs: {
description: {
component:
"<p>Renders an select input field with a list of options to choose from.</p>" +
"<p>The list is virtualized so can handle large amount of items</p>",
},
},
},
argTypes: {
showSearchBox: {
Expand All @@ -56,6 +63,19 @@ const meta: Meta = {
searchBoxPlaceholder: {
description: "Placeholder for the search box",
},
options: {
description:
"<p>Array of options to show in the select. Each option can its an object of type `SelectItemObject` or `'separator'`" +
" to render a separator line</p>" +
"```" +
"type SelectItemObject<T> = {\n" +
" value: T\n" +
" label: string\n" +
" description?: string\n" +
" avatar?: AvatarVariant\n" +
" icon?: IconType\n" +
"}```",
},
},
args: {
placeholder: "Select a theme",
Expand All @@ -81,12 +101,6 @@ const meta: Meta = {
icon: Desktop,
description: "A theme that adapts to the system's default appearance",
},
...Array.from({ length: 10 }, (_, i) => ({
value: `option-${i}`,
label: `Option ${i}`,
icon: Circle,
description: `Description for option ${i}`,
})),
],
disabled: false,
showSearchBox: false,
Expand All @@ -107,6 +121,22 @@ export const WithSearchBox: Story = {
},
}

export const LargeList: Story = {
args: {
...WithSearchBox.args,
options: [
...(meta.args?.options || []),
"separator",
...Array.from({ length: 10000 }, (_, i) => ({
value: `option-${i}`,
label: `Option ${i}`,
icon: Circle,
description: `Description for option ${i}`,
})),
],
},
}

export const WithCustomTrigger: Story = {
args: {
placeholder: "Choose a color",
Expand Down
86 changes: 42 additions & 44 deletions lib/experimental/Forms/Fields/Select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
SelectSeparator,
SelectTrigger,
SelectValue as SelectValuePrimitive,
} from "@/ui/select"
VirtualItem,
} from "@/ui/Select"
import { forwardRef, useEffect, useMemo, useRef, useState } from "react"
import type { SelectItemObject, SelectItemProps } from "./types"

export * from "./types"

export type SelectProps<T> = {
Expand Down Expand Up @@ -72,30 +74,6 @@ const SelectValue = forwardRef<
)
})

const SelectOptions = ({
options,
emptyMessage,
}: {
options: SelectProps<string>["options"]
emptyMessage?: string
}) => {
return (
<>
{options.filter((option) => option !== "separator").length ? (
options.map((option, index) =>
option === "separator" ? (
<SelectSeparator key={`separator-${index}`} />
) : (
<SelectItem key={option.value} item={option} />
)
)
) : (
<p className="p-2 text-center">{emptyMessage}</p>
)}
</>
)
}

const defaultTrigger =
"flex h-10 w-full items-center justify-between rounded-md border border-solid border-f1-border bg-f1-background pl-3 pr-2 py-2.5 transition-colors placeholder:text-f1-foreground-secondary hover:border-f1-border-hover disabled:cursor-not-allowed disabled:bg-f1-background-secondary disabled:opacity-50 [&>span]:line-clamp-1"

Expand Down Expand Up @@ -127,6 +105,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps<string>>(
const searchInputRef = useRef<HTMLInputElement>(null)

const [searchValue, setSearchValue] = useState(props.searchValue || "")
const [openLocal, setOpenLocal] = useState(open)

const filteredOptions = useMemo(() => {
if (externalSearch) {
Expand Down Expand Up @@ -166,11 +145,29 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps<string>>(

const onOpenChangeLocal = (open: boolean) => {
onOpenChange?.(open)
setOpenLocal(open)
setTimeout(() => {
searchInputRef.current?.focus()
}, 0)
}

const items: VirtualItem[] = useMemo(
() =>
filteredOptions.map((option, index) =>
option === "separator"
? {
height: 1,
item: <SelectSeparator key={`separator-${index}`} />,
}
: {
height: 64,
item: <SelectItem key={option.value} item={option} />,
value: option.value,
}
),
[filteredOptions]
)

return (
<SelectPrimitive
onValueChange={onValueChange}
Expand Down Expand Up @@ -199,26 +196,27 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps<string>>(
</button>
)}
</SelectTrigger>
<SelectContent>
<SelectContent.Top>
{showSearchBox && (
<div className="p-3 pb-2">
<F1SearchBox
placeholder={searchBoxPlaceholder}
onChange={onSearchChangeLocal}
clearable
value={searchValue}
key="search-input"
ref={searchInputRef}
/>
</div>
)}
</SelectContent.Top>
<SelectOptions
options={filteredOptions}
{openLocal && (
<SelectContent
items={items}
emptyMessage={searchEmptyMessage}
/>
</SelectContent>
value={value}
top={
showSearchBox && (
<div className="p-3 pb-2">
<F1SearchBox
placeholder={searchBoxPlaceholder}
onChange={onSearchChangeLocal}
clearable
value={searchValue}
key="search-input"
ref={searchInputRef}
/>
</div>
)
}
></SelectContent>
)}
</SelectPrimitive>
)
}
Expand Down
29 changes: 29 additions & 0 deletions lib/ui/Select/components/SelectItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Icon } from "@/components/Utilities/Icon"
import { CheckCircle } from "@/icons/app"
import { cn } from "@/lib/utils.ts"
import * as SelectPrimitive from "@radix-ui/react-select"
import * as React from "react"

const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative grid w-full cursor-default select-none grid-cols-[1fr_20px] gap-x-1.5 rounded px-3 py-2 outline-none transition-colors after:absolute after:inset-x-1 after:inset-y-0 after:z-0 after:h-full after:rounded after:bg-f1-background-hover after:opacity-0 after:transition-opacity after:duration-75 after:content-[''] first:pt-3 first:after:top-1 first:after:h-[calc(100%-0.25rem)] last:pb-3 last:after:bottom-1 last:after:h-[calc(100%-0.25rem)] hover:after:opacity-100 focus:after:bg-f1-background-hover focus:after:text-f1-foreground focus:after:opacity-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:z-10",
"data-[state=checked]:after:bg-f1-background-selected-bold/10 data-[state=checked]:after:opacity-100 hover:data-[state=checked]:after:bg-f1-background-selected-bold/10 dark:data-[state=checked]:after:bg-f1-background-selected-bold/20 dark:hover:data-[state=checked]:after:bg-f1-background-selected-bold/20",
"focus:outline-none focus:ring-0 focus:ring-transparent", // Temporal fix for Gamma issue
className
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="flex text-f1-icon-selected">
<Icon icon={CheckCircle} size="md" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName

export { SelectItem }
84 changes: 84 additions & 0 deletions lib/ui/Select/components/SelectItemsVirtual.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useVirtualizer } from "@tanstack/react-virtual"
import { MutableRefObject, useCallback, useEffect } from "react"
import { VirtualItem } from "../typings"

type Props = {
items: VirtualItem[]
parentRef: MutableRefObject<HTMLDivElement | null>
positionIndex?: number
}
/**
* Renders the items as a virtual list
* @param items {VirtualItem} The items to render
* @param parentRef {Ref} The parent container reference to calculate position
* @param positionIndex {number} The index of the selected item
* @constructor
*/
export const SelectItemsVirtual = ({
items,
parentRef,
positionIndex,
}: Props) => {
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: (i: number) => items[i].height,
overscan: 5,
})

/**
* Scroll to the selected item
* @param index {number} The index of the item to scroll to
* @returns {void}
*/
const scrollToIndex = useCallback(
(index: number) => {
virtualizer.scrollToIndex(index, {
align: "center",
})
},
[virtualizer]
)

// Scroll to the selected item
useEffect(() => {
if (positionIndex !== undefined) {
scrollToIndex(positionIndex)
setTimeout(() => {
scrollToIndex(positionIndex)
}, 1)
}
}, [positionIndex, scrollToIndex])

// Measure the virtual items
useEffect(() => {
virtualizer.measure()
}, [virtualizer, items])

return (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
background: "#f00",
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
height: `${virtualItem.size}px`,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].item}
</div>
))}
</div>
)
}
17 changes: 17 additions & 0 deletions lib/ui/Select/components/SelectLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils.ts"
import * as SelectPrimitive from "@radix-ui/react-select"
import * as React from "react"

const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName

export { SelectLabel }
45 changes: 45 additions & 0 deletions lib/ui/Select/components/SelectScrollButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Icon } from "@/components/Utilities/Icon"
import { ChevronDown, ChevronUp } from "@/icons/app"
import { cn } from "@/lib/utils.ts"
import * as SelectPrimitive from "@radix-ui/react-select"
import { forwardRef } from "react"

type Props = {
variant: "up" | "down"
className?: string
}
const SelectScrollButton = ({ variant, ...props }: Props) => {
type ScrollButton = typeof variant extends "up"
? typeof SelectPrimitive.ScrollUpButton
: typeof SelectPrimitive.ScrollDownButton

const Component = forwardRef<
React.ElementRef<ScrollButton>,
React.ComponentPropsWithoutRef<ScrollButton>
>(({ className, ...props }, ref) => {
const WrapperComponent =
variant === "up"
? SelectPrimitive.ScrollUpButton
: SelectPrimitive.ScrollDownButton

return (
<WrapperComponent
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1 text-f1-icon",
className
)}
{...props}
>
<Icon icon={variant === "up" ? ChevronUp : ChevronDown} size="sm" />
</WrapperComponent>
)
})
Component.displayName =
variant === "up"
? SelectPrimitive.ScrollUpButton.displayName
: SelectPrimitive.ScrollDownButton.displayName

return <Component {...props} />
}
export { SelectScrollButton }
17 changes: 17 additions & 0 deletions lib/ui/Select/components/SelectSeparator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils.ts"
import * as SelectPrimitive from "@radix-ui/react-select"
import * as React from "react"

const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-f1-border-secondary", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName

export { SelectSeparator }
Loading

0 comments on commit c63b27d

Please sign in to comment.