diff --git a/.cursor/rules/02-typescript-testing.mdc b/.cursor/rules/02-typescript-testing.mdc index 857e7a97f0..a3e7d12ba5 100644 --- a/.cursor/rules/02-typescript-testing.mdc +++ b/.cursor/rules/02-typescript-testing.mdc @@ -11,7 +11,8 @@ alwaysApply: false - Use discriminated unions for complex state. - Export component prop interfaces. - Implement proper generic constraints. -- `any` is a non aceptable type, avoid its use +- `any` is a non aceptable type, avoid its use +- Don't use `any` or `as any` to solve types issues # Testing Standards diff --git a/packages/react/package.json b/packages/react/package.json index 303fd46da0..a0d6688ea7 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -177,8 +177,26 @@ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^3.2.5", "@factorialco/f0-core": "workspace:*", - "@radix-ui/react-hover-card": "^1.1.6", - "@radix-ui/react-switch": "^1.2.2", + "@radix-ui/number": "^1.1.1", + "@radix-ui/primitive": "^1.1.2", + "@radix-ui/react-collection": "^1.1.7", + "@radix-ui/react-compose-refs": "^1.1.2", + "@radix-ui/react-context": "^1.1.2", + "@radix-ui/react-direction": "^1.1.1", + "@radix-ui/react-dismissable-layer": "^1.1.10", + "@radix-ui/react-focus-guards": "^1.1.2", + "@radix-ui/react-focus-scope": "^1.1.7", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-id": "^1.1.1", + "@radix-ui/react-popper": "^1.2.7", + "@radix-ui/react-portal": "^1.1.9", + "@radix-ui/react-primitive": "^2.1.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-use-callback-ref": "^1.1.1", + "@radix-ui/react-use-controllable-state": "^1.2.2", + "@radix-ui/react-use-layout-effect": "^1.1.1", + "@radix-ui/react-use-previous": "^1.1.1", + "@radix-ui/react-visually-hidden": "^1.2.3", "@tanstack/react-virtual": "^3.13.2", "@tiptap/core": "^2.24.0", "@tiptap/extension-bubble-menu": "^2.11.5", @@ -206,6 +224,7 @@ "@tiptap/starter-kit": "^2.11.5", "@tiptap/suggestion": "^2.12.0", "@vueless/storybook-dark-mode": "^9.0.7", + "aria-hidden": "^1.2.6", "cva": "1.0.0-beta.3", "dompurify": "^3.2.6", "embla-carousel-autoplay": "^8.5.2", @@ -213,6 +232,7 @@ "embla-carousel-wheel-gestures": "^8.0.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.38.1", + "react-remove-scroll": "^2.7.1", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", diff --git a/packages/react/src/experimental/Forms/Fields/Select/index.tsx b/packages/react/src/experimental/Forms/Fields/Select/index.tsx index 278056d525..08774bd051 100644 --- a/packages/react/src/experimental/Forms/Fields/Select/index.tsx +++ b/packages/react/src/experimental/Forms/Fields/Select/index.tsx @@ -3,7 +3,6 @@ import { F0Icon } from "@/components/F0Icon" import { OneEllipsis } from "@/components/OneEllipsis" import { F0TagRaw } from "@/components/tags/F0TagRaw" -import { GroupHeader } from "@/experimental/OneDataCollection/components/GroupHeader" import { BaseFetchOptions, BaseResponse, @@ -22,6 +21,7 @@ import { } from "@/hooks/datasource" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" +import { GroupHeader } from "@/ui/GroupHeader/index" import { InputField, InputFieldProps } from "@/ui/InputField" import { SelectContent, diff --git a/packages/react/src/experimental/OneDataCollection/visualizations/collection/Card/index.tsx b/packages/react/src/experimental/OneDataCollection/visualizations/collection/Card/index.tsx index 24d38d4367..158357597a 100644 --- a/packages/react/src/experimental/OneDataCollection/visualizations/collection/Card/index.tsx +++ b/packages/react/src/experimental/OneDataCollection/visualizations/collection/Card/index.tsx @@ -3,7 +3,6 @@ import { CardAvatarVariant } from "@/components/F0Card/components/CardAvatar" import { cardPropertyRenderers } from "@/components/F0Card/components/CardMetadata" import { CardMetadata, CardMetadataProperty } from "@/components/F0Card/types" import { IconType } from "@/components/F0Icon" -import { GroupHeader } from "@/experimental/OneDataCollection/components/GroupHeader/GroupHeader" import { useDataCollectionData } from "@/experimental/OneDataCollection/hooks/useDataCollectionData" import { DataCollectionSource } from "@/experimental/OneDataCollection/hooks/useDataCollectionSource" import { NavigationFiltersDefinition } from "@/experimental/OneDataCollection/navigationFilters/types" @@ -13,6 +12,7 @@ import { getAnimationVariants, useGroups } from "@/hooks/datasource/useGroups" import { useSelectable } from "@/hooks/datasource/useSelectable" import { Placeholder } from "@/icons/app" import { Card, CardContent, CardHeader, CardTitle } from "@/ui/Card" +import { GroupHeader } from "@/ui/GroupHeader/GroupHeader" import { Skeleton } from "@/ui/skeleton" import { AnimatePresence, motion } from "motion/react" import { useEffect, useMemo } from "react" diff --git a/packages/react/src/experimental/OneDataCollection/visualizations/collection/List/index.tsx b/packages/react/src/experimental/OneDataCollection/visualizations/collection/List/index.tsx index 1c54019a14..edfd5652a7 100644 --- a/packages/react/src/experimental/OneDataCollection/visualizations/collection/List/index.tsx +++ b/packages/react/src/experimental/OneDataCollection/visualizations/collection/List/index.tsx @@ -1,7 +1,7 @@ import { NavigationFiltersDefinition } from "@/experimental/OneDataCollection/navigationFilters/types" -import { GroupHeader } from "@/experimental/OneDataCollection/components/GroupHeader/GroupHeader" import { useGroups } from "@/hooks/datasource/useGroups" +import { GroupHeader } from "@/ui/GroupHeader/GroupHeader" import { useDataCollectionData } from "@/experimental/OneDataCollection/hooks/useDataCollectionData" import { useInfiniteScrollPagination } from "@/experimental/OneDataCollection/hooks/useInfiniteScrollPagination" diff --git a/packages/react/src/experimental/OneDataCollection/visualizations/collection/Table/Table.tsx b/packages/react/src/experimental/OneDataCollection/visualizations/collection/Table/Table.tsx index 826c2517da..1a54661ec1 100644 --- a/packages/react/src/experimental/OneDataCollection/visualizations/collection/Table/Table.tsx +++ b/packages/react/src/experimental/OneDataCollection/visualizations/collection/Table/Table.tsx @@ -1,5 +1,4 @@ import { F0Checkbox } from "@/components/F0Checkbox" -import { GroupHeader } from "@/experimental/OneDataCollection/components/GroupHeader" import { PagesPagination } from "@/experimental/OneDataCollection/components/PagesPagination" import { useDataCollectionSettings } from "@/experimental/OneDataCollection/Settings/SettingsProvider" import { @@ -25,6 +24,7 @@ import { } from "@/hooks/datasource" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" +import { GroupHeader } from "@/ui/GroupHeader/index" import { Skeleton } from "@/ui/skeleton.tsx" import { AnimatePresence, motion } from "motion/react" import { Fragment, useEffect, useMemo, useState } from "react" diff --git a/packages/react/src/experimental/OneDataCollection/components/GroupHeader/GroupHeader.tsx b/packages/react/src/ui/GroupHeader/GroupHeader.tsx similarity index 100% rename from packages/react/src/experimental/OneDataCollection/components/GroupHeader/GroupHeader.tsx rename to packages/react/src/ui/GroupHeader/GroupHeader.tsx diff --git a/packages/react/src/experimental/OneDataCollection/components/GroupHeader/__tests__/GroupHeader.test.tsx b/packages/react/src/ui/GroupHeader/__tests__/GroupHeader.test.tsx similarity index 100% rename from packages/react/src/experimental/OneDataCollection/components/GroupHeader/__tests__/GroupHeader.test.tsx rename to packages/react/src/ui/GroupHeader/__tests__/GroupHeader.test.tsx diff --git a/packages/react/src/experimental/OneDataCollection/components/GroupHeader/index.tsx b/packages/react/src/ui/GroupHeader/index.ts similarity index 100% rename from packages/react/src/experimental/OneDataCollection/components/GroupHeader/index.tsx rename to packages/react/src/ui/GroupHeader/index.ts diff --git a/packages/react/src/ui/GroupHeader/index.tsx b/packages/react/src/ui/GroupHeader/index.tsx new file mode 100644 index 0000000000..27e14bcb3d --- /dev/null +++ b/packages/react/src/ui/GroupHeader/index.tsx @@ -0,0 +1 @@ +export * from "./GroupHeader" diff --git a/packages/react/src/ui/Select/Select.tsx b/packages/react/src/ui/Select/Select.tsx index 7a88642493..294f9889e6 100644 --- a/packages/react/src/ui/Select/Select.tsx +++ b/packages/react/src/ui/Select/Select.tsx @@ -1,6 +1,6 @@ "use client" -import * as SelectPrimitive from "@radix-ui/react-select" +import * as SelectPrimitive from "./components/radix-ui" /** * Select Group component diff --git a/packages/react/src/ui/Select/SelectContext.tsx b/packages/react/src/ui/Select/SelectContext.tsx index d336af7080..ce8be20f8f 100644 --- a/packages/react/src/ui/Select/SelectContext.tsx +++ b/packages/react/src/ui/Select/SelectContext.tsx @@ -1,15 +1,16 @@ import { createContext, useContext } from "react" -type SelectContextType = { - value?: string +export type SelectContextType = { open?: boolean asList?: boolean + multiple?: boolean + value: string[] | string } - export const SelectContext = createContext({ - value: undefined, + value: "", open: false, asList: false, + multiple: false, }) export const useSelectContext = () => useContext(SelectContext) diff --git a/packages/react/src/ui/Select/select.stories.tsx b/packages/react/src/ui/Select/__stories__/select.stories.tsx similarity index 65% rename from packages/react/src/ui/Select/select.stories.tsx rename to packages/react/src/ui/Select/__stories__/select.stories.tsx index 94e428d53f..7188124e59 100644 --- a/packages/react/src/ui/Select/select.stories.tsx +++ b/packages/react/src/ui/Select/__stories__/select.stories.tsx @@ -1,29 +1,73 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import { useMemo, useState } from "react" -import { Circle, Desktop } from "../../icons/app" +import { Circle, Desktop } from "../../../icons/app" import { Select, SelectContent, SelectItem, + SelectProps, SelectTrigger, SelectValue, -} from "./index" +} from "../index" const SelectWithHooks = ({ options, placeholder, asList, ...props -}: { - options: { value: string; label: string }[] - placeholder?: string - asList?: boolean -}) => { - const [value, setValue] = useState("") +}: SelectProps) => { + const { value, multiple } = props + const [localValue, setLocalValue] = useState( + value + ) + + const RenderSelect = ( + props: Omit & { + children: React.ReactNode + value: string | string[] | undefined + onValueChange: (value: string | string[]) => void + } + ) => { + const { value: initialValue, defaultValue: _, multiple, ...rest } = props + + if (multiple) { + const [value, setValue] = useState( + initialValue as string[] | undefined + ) + const handleChange = (value: string[]) => { + console.log("value", value) + setValue(value) + props.onValueChange(value) + } + return ( + + ) + } else { + const [value, setValue] = useState( + props.value as string | undefined + ) + const handleChange = (value: string) => { + console.log("value", value) + setValue(value) + props.onValueChange(value) + } + + return ( + - - {value} - - - - + <> + + + {localValue} + + + + +
Selected: {JSON.stringify(localValue)}
+ ) } @@ -49,6 +106,7 @@ const meta = { a11y: { skipCi: true, // Todo add aria labels }, + //layout: "centered", docs: { description: { component: @@ -63,6 +121,7 @@ const meta = { }, args: { placeholder: "Select an option", + multiple: false, options: [ { value: "light", label: "Light" }, { value: "dark", label: "Dark" }, @@ -95,6 +154,31 @@ export const AsList: Story = { }, } +export const Multiple: Story = { + args: { + value: ["light", "system"], + multiple: true, + }, + render: ({ options, placeholder }) => { + const [value, setValue] = useState([]) + + return ( + + ) + }, +} + export const WithTopContent: Story = { render: ({ options, placeholder }) => { const [value, setValue] = useState("") @@ -107,7 +191,7 @@ export const WithTopContent: Story = { Top Content} > - {options.map((option) => ( + {options?.map((option) => ( {option.label} @@ -132,7 +216,7 @@ export const WithBottomContent: Story = {
Bottom Content
} > - {options.map((option) => ( + {options?.map((option) => ( {option.label} @@ -166,7 +250,7 @@ export const WithBothTopAndBottom: Story = { } > - {options.map((option) => ( + {options?.map((option) => ( {option.label} @@ -204,7 +288,7 @@ export const WithCustomTrigger: Story = { - {options.map((option) => ( + {options?.map((option) => (
diff --git a/packages/react/src/ui/Select/components/Select.tsx b/packages/react/src/ui/Select/components/Select.tsx index f9ff45d48d..312799de28 100644 --- a/packages/react/src/ui/Select/components/Select.tsx +++ b/packages/react/src/ui/Select/components/Select.tsx @@ -1,19 +1,27 @@ -import * as SelectPrimitive from "@radix-ui/react-select" -import * as React from "react" -import { useState } from "react" -import { SelectContext } from "../SelectContext.tsx" +import { useEffect, useState } from "react" +import { SelectContext, SelectContextType } from "../SelectContext.tsx" +import * as SelectPrimitive from "./radix-ui" +import { SelectPrimitiveProps } from "./radix-ui/select.tsx" -type SelectProps = React.ComponentProps & { +type SelectOption = { + value: string + label: string +} + +export type SelectProps = SelectPrimitiveProps & { asList?: boolean + placeholder?: string + options?: SelectOption[] } + /** * Select Root component */ -const Select = (props: SelectProps) => { - // If open prop is not provided, we'll manage it internally + +const Select = (props: SelectProps) => { + type Value = NonNullable const [internalOpen, setInternalOpen] = useState(props.asList ? true : false) - // Use either the controlled open state from props or the internal state const isOpen = props.asList ? true : props.open !== undefined @@ -30,28 +38,73 @@ const Select = (props: SelectProps) => { props.onOpenChange?.(open) } + const toArray = (value: T | T[] | undefined) => { + if (value === undefined) { + return [] + } + return Array.isArray(value) ? value : [value] + } + + const [localValue, setLocalValue] = useState(toArray(props.value)) + + /** + * We need to update the local value when the value prop changes + * Internally we use always an array of values, so we need to convert the value to an array + */ + useEffect( + () => { + setLocalValue(toArray(props.value)) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- we are checking deeply the value + [JSON.stringify(props.value)] + ) + + const contextValue: SelectContextType = { + value: localValue, + open: isOpen, + asList: props.asList, + multiple: props.multiple || false, + } + + const commonProps = { + ...props, + open: isOpen, + onOpenChange: handleOpenChange, + children: ( + + {props.children} + + ), + } + + const handleValueChange = (value: Value) => { + setLocalValue(toArray(value)) + if (props.multiple) { + props.onValueChange?.(toArray(value) as T[]) + } else { + props.onValueChange?.(value as T) + } + } + + const primitiveProps = props.multiple + ? { + ...commonProps, + multiple: true as const, + value: localValue, + defaultValue: props.defaultValue, + onValueChange: handleValueChange, + } + : { + ...commonProps, + multiple: false as const, + value: localValue[0], + defaultValue: props.defaultValue, + onValueChange: handleValueChange, + } + return ( -
{ - e.preventDefault() - }} - > - - - {props.children} - - +
+ {...primitiveProps} />
) } diff --git a/packages/react/src/ui/Select/components/SelectContent.tsx b/packages/react/src/ui/Select/components/SelectContent.tsx index 8b23b924d8..3fa7be12d5 100644 --- a/packages/react/src/ui/Select/components/SelectContent.tsx +++ b/packages/react/src/ui/Select/components/SelectContent.tsx @@ -3,7 +3,6 @@ import { useReducedMotion } from "@/lib/a11y" import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" import { ScrollArea } from "@/ui/scrollarea" -import * as SelectPrimitive from "@radix-ui/react-select" import { useVirtualizer } from "@tanstack/react-virtual" import { ComponentPropsWithoutRef, @@ -18,6 +17,7 @@ import { } from "react" import { VirtualItem } from "../index" import { SelectContext } from "../SelectContext" +import * as SelectPrimitive from "./radix-ui" const VIEWBOX_VERTICAL_PADDING = 8 @@ -31,18 +31,24 @@ type SelectItemProps = ComponentPropsWithoutRef< top?: ReactNode bottom?: ReactNode emptyMessage?: string - value?: string showLoadingIndicator?: boolean -} - -type BaseSelectContentProps = Omit +} & ( + | { + value?: string[] + multiple: true + } + | { + value?: string + multiple?: false + } + ) -type SelectContentWithItemsProps = BaseSelectContentProps & { +type SelectContentWithItemsProps = Omit & { items: VirtualItem[] children?: never } -type SelectContentWithChildrenProps = SelectItemProps & { +type SelectContentWithChildrenProps = Omit & { items?: never children: ReactNode } @@ -58,7 +64,6 @@ type SelectContentProps = ( isLoading?: boolean scrollMargin?: number } - const SelectContent = forwardRef< ElementRef, SelectContentProps @@ -103,9 +108,23 @@ const SelectContent = forwardRef< // Get the value and the open status from the select context const { value, open, asList } = useContext(SelectContext) + const valueArray = useMemo( + () => + new Set( + (Array.isArray(value) ? value : [value]).filter( + (item) => item !== undefined + ) + ), + [value] + ) + const positionIndex = useMemo(() => { - return (items && items.findIndex((item) => item.value === value)) || 0 - }, [items, value]) + return ( + items?.findIndex( + (item) => item.value !== undefined && valueArray.has(item.value) + ) || 0 + ) + }, [items, valueArray]) const virtualizer = useVirtualizer({ count: items?.length || 0, @@ -257,13 +276,15 @@ const SelectContent = forwardRef< <> {/* Overlay to prevent clicks on the content */} -
{ - e.preventDefault() - e.stopPropagation() - }} - >
+ {open && ( +
{ + e.preventDefault() + e.stopPropagation() + }} + >
+ )} {content}
diff --git a/packages/react/src/ui/Select/components/SelectItem.tsx b/packages/react/src/ui/Select/components/SelectItem.tsx index 3d25f8bdfd..6bfedc77de 100644 --- a/packages/react/src/ui/Select/components/SelectItem.tsx +++ b/packages/react/src/ui/Select/components/SelectItem.tsx @@ -1,34 +1,58 @@ -import * as SelectPrimitive from "@radix-ui/react-select" +import { F0Icon } from "@/components/F0Icon" +import { CheckCircle } from "@/icons/app" +import { cn } from "@/lib/utils.ts" +import { Checkbox } from "@/ui/checkbox" import * as React from "react" -import { F0Icon } from "../../../components/F0Icon/index.tsx" -import { CheckCircle } from "../../../icons/app" -import { cn } from "../../../lib/utils.ts" +import { useMemo } from "react" +import { useSelectContext } from "../SelectContext.tsx" +import * as SelectPrimitive from "./radix-ui" const SelectItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - withIndicator?: boolean + selected?: boolean + multiple?: boolean } ->(({ className, children, withIndicator = true, ...props }, ref) => ( - - {children} - {withIndicator && ( - - - - )} - -)) +>(({ className, children, ...props }, ref) => { + const context = useSelectContext() + const { multiple } = context + + const selected = useMemo(() => { + if (Array.isArray(context.value)) { + return context.value.includes(props.value as string) + } + return context.value === props.value + }, [context.value, props.value]) + + return ( + + {children} + {multiple ? ( + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + checked={selected} + /> + ) : ( + selected && ( + + + + ) + )} + + ) +}) SelectItem.displayName = SelectPrimitive.Item.displayName export { SelectItem } diff --git a/packages/react/src/ui/Select/components/SelectLabel.tsx b/packages/react/src/ui/Select/components/SelectLabel.tsx index 7d84a84bfc..4b71099586 100644 --- a/packages/react/src/ui/Select/components/SelectLabel.tsx +++ b/packages/react/src/ui/Select/components/SelectLabel.tsx @@ -1,6 +1,6 @@ -import * as SelectPrimitive from "@radix-ui/react-select" +import { cn } from "@/lib/utils.ts" import * as React from "react" -import { cn } from "../../../lib/utils.ts" +import * as SelectPrimitive from "./radix-ui" const SelectLabel = React.forwardRef< React.ElementRef, diff --git a/packages/react/src/ui/Select/components/SelectScrollButton.tsx b/packages/react/src/ui/Select/components/SelectScrollButton.tsx index 562a046634..603e14fd2b 100644 --- a/packages/react/src/ui/Select/components/SelectScrollButton.tsx +++ b/packages/react/src/ui/Select/components/SelectScrollButton.tsx @@ -1,8 +1,8 @@ -import * as SelectPrimitive from "@radix-ui/react-select" +import { F0Icon } from "@/components/F0Icon" +import { ChevronDown, ChevronUp } from "@/icons/app" +import { cn } from "@/lib/utils.ts" import { forwardRef } from "react" -import { F0Icon } from "../../../components/F0Icon/index.tsx" -import { ChevronDown, ChevronUp } from "../../../icons/app" -import { cn } from "../../../lib/utils.ts" +import * as SelectPrimitive from "./radix-ui" type Props = { variant: "up" | "down" diff --git a/packages/react/src/ui/Select/components/SelectSeparator.tsx b/packages/react/src/ui/Select/components/SelectSeparator.tsx index 7514ebe83f..da7a4e9ad1 100644 --- a/packages/react/src/ui/Select/components/SelectSeparator.tsx +++ b/packages/react/src/ui/Select/components/SelectSeparator.tsx @@ -1,6 +1,6 @@ -import * as SelectPrimitive from "@radix-ui/react-select" +import { cn } from "@/lib/utils.ts" import * as React from "react" -import { cn } from "../../../lib/utils.ts" +import * as SelectPrimitive from "./radix-ui" const SelectSeparator = React.forwardRef< React.ElementRef, diff --git a/packages/react/src/ui/Select/components/SelectTrigger.tsx b/packages/react/src/ui/Select/components/SelectTrigger.tsx index c07a3a028b..eab0ef7db2 100644 --- a/packages/react/src/ui/Select/components/SelectTrigger.tsx +++ b/packages/react/src/ui/Select/components/SelectTrigger.tsx @@ -1,8 +1,8 @@ -import * as SelectPrimitive from "@radix-ui/react-select" +import { cn } from "@/lib/utils.ts" import * as React from "react" import { useContext } from "react" -import { cn } from "../../../lib/utils.ts" import { SelectContext } from "../SelectContext.tsx" +import * as SelectPrimitive from "./radix-ui" /** * Select Trigger component diff --git a/packages/react/src/ui/Select/components/radix-ui/README.md b/packages/react/src/ui/Select/components/radix-ui/README.md new file mode 100644 index 0000000000..333521411e --- /dev/null +++ b/packages/react/src/ui/Select/components/radix-ui/README.md @@ -0,0 +1,6 @@ +# IMPORTANT + +This code was extracted from Radix-ui select in order to add support for +multiselection to the Select primitive + +https://github.com/radix-ui/primitives/blob/b3ee588dcb339d6c6ce524fcdd968c5eeb4e8458/packages/react/select/src/select.tsx diff --git a/packages/react/src/ui/Select/components/radix-ui/index.ts b/packages/react/src/ui/Select/components/radix-ui/index.ts new file mode 100644 index 0000000000..1690e35022 --- /dev/null +++ b/packages/react/src/ui/Select/components/radix-ui/index.ts @@ -0,0 +1,56 @@ +"use client" +export { + Arrow, + Content, + Group, + Icon, + Item, + ItemIndicator, + ItemText, + Label, + Portal, + // + Root, + ScrollDownButton, + ScrollUpButton, + // + Select, + SelectArrow, + SelectContent, + SelectGroup, + SelectIcon, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectPortal, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, + SelectViewport, + Separator, + Trigger, + Value, + Viewport, + createSelectScope, +} from "./select" +export type { + SelectArrowProps, + SelectContentProps, + SelectGroupProps, + SelectIconProps, + SelectItemIndicatorProps, + SelectItemProps, + SelectItemTextProps, + SelectLabelProps, + SelectPortalProps, + SelectProps, + SelectScrollDownButtonProps, + SelectScrollUpButtonProps, + SelectSeparatorProps, + SelectTriggerProps, + SelectValueProps, + SelectViewportProps, +} from "./select" diff --git a/packages/react/src/ui/Select/components/radix-ui/select.tsx b/packages/react/src/ui/Select/components/radix-ui/select.tsx new file mode 100644 index 0000000000..d51aaa708f --- /dev/null +++ b/packages/react/src/ui/Select/components/radix-ui/select.tsx @@ -0,0 +1,2109 @@ +import { clamp } from "@radix-ui/number" +import { composeEventHandlers } from "@radix-ui/primitive" +import { createCollection } from "@radix-ui/react-collection" +import { useComposedRefs } from "@radix-ui/react-compose-refs" +import { createContextScope } from "@radix-ui/react-context" +import { useDirection } from "@radix-ui/react-direction" +import { DismissableLayer } from "@radix-ui/react-dismissable-layer" +import { useFocusGuards } from "@radix-ui/react-focus-guards" +import { FocusScope } from "@radix-ui/react-focus-scope" +import { useId } from "@radix-ui/react-id" +import * as PopperPrimitive from "@radix-ui/react-popper" +import { createPopperScope } from "@radix-ui/react-popper" +import { Portal as PortalPrimitive } from "@radix-ui/react-portal" +import { Primitive } from "@radix-ui/react-primitive" +import { createSlot } from "@radix-ui/react-slot" +import { useCallbackRef } from "@radix-ui/react-use-callback-ref" +import { useControllableState } from "@radix-ui/react-use-controllable-state" +import { useLayoutEffect } from "@radix-ui/react-use-layout-effect" +import { usePrevious } from "@radix-ui/react-use-previous" +import { VISUALLY_HIDDEN_STYLES } from "@radix-ui/react-visually-hidden" +import { hideOthers } from "aria-hidden" +import * as React from "react" +import * as ReactDOM from "react-dom" +import { RemoveScroll } from "react-remove-scroll" + +import type { Scope } from "@radix-ui/react-context" + +type Direction = "ltr" | "rtl" + +const OPEN_KEYS = [" ", "Enter", "ArrowUp", "ArrowDown"] +const SELECTION_KEYS = [" ", "Enter"] + +/* ------------------------------------------------------------------------------------------------- + * Select + * -----------------------------------------------------------------------------------------------*/ + +const SELECT_NAME = "Select" + +type ItemData = { value: string; disabled: boolean; textValue: string } +const [Collection, useCollection, createCollectionScope] = createCollection< + SelectItemElement, + ItemData +>(SELECT_NAME) + +type ScopedProps

= P & { __scopeSelect?: Scope } +const [createSelectContext, createSelectScope] = createContextScope( + SELECT_NAME, + [createCollectionScope, createPopperScope] +) +const usePopperScope = createPopperScope() + +type SelectContextValue = { + trigger: SelectTriggerElement | null + onTriggerChange(node: SelectTriggerElement | null): void + valueNode: SelectValueElement | null + onValueNodeChange(node: SelectValueElement): void + valueNodeHasChildren: boolean + onValueNodeHasChildrenChange(hasChildren: boolean): void + contentId: string + value: T[] | T | undefined + onValueChange(value: T[] | T): void + onItemCheckChange?: (value: T, checked: boolean) => void + open: boolean + required?: boolean + multiple?: boolean + onOpenChange(open: boolean): void + dir: SelectProps["dir"] + triggerPointerDownPosRef: React.MutableRefObject<{ + x: number + y: number + } | null> + disabled?: boolean +} + +const [SelectProvider, useSelectContext] = + createSelectContext(SELECT_NAME) + +type NativeOption = React.ReactElement> + +type SelectNativeOptionsContextValue = { + onNativeOptionAdd(option: NativeOption): void + onNativeOptionRemove(option: NativeOption): void +} +const [SelectNativeOptionsProvider, useSelectNativeOptionsContext] = + createSelectContext(SELECT_NAME) + +interface SelectSharedProps { + children?: React.ReactNode + open?: boolean + defaultOpen?: boolean + onOpenChange?: (open: boolean) => void + dir?: Direction + name?: string + autoComplete?: string + disabled?: boolean + required?: boolean + form?: string + onItemCheckChange?: (value: string, checked: boolean) => void +} + +type SelectProps = SelectSharedProps & + ( + | { + value?: T + defaultValue?: T + onValueChange?(value: T): void + multiple?: false | never + } + | { + value?: T[] + defaultValue?: T[] + onValueChange?(value: T[]): void + multiple: true + } + ) + +export type { SelectProps as SelectPrimitiveProps } + +const Select = ( + props: ScopedProps> +) => { + const { + __scopeSelect, + children, + open: openProp, + defaultOpen, + onOpenChange, + value: valueProp, + defaultValue, + onValueChange, + onItemCheckChange, + dir, + name, + autoComplete, + disabled, + required, + form, + multiple, + } = props + const popperScope = usePopperScope(__scopeSelect) + const [trigger, setTrigger] = React.useState( + null + ) + const [valueNode, setValueNode] = React.useState( + null + ) + const [valueNodeHasChildren, setValueNodeHasChildren] = React.useState(false) + const direction = useDirection(dir) + const [open, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen ?? false, + onChange: onOpenChange, + caller: SELECT_NAME, + }) + const [value, setValue] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange: onValueChange as any, + caller: SELECT_NAME, + }) + const triggerPointerDownPosRef = React.useRef<{ + x: number + y: number + } | null>(null) + + // We set this to true by default so that events bubble to forms without JS (SSR) + const isFormControl = trigger ? form || !!trigger.closest("form") : true + const [nativeOptionsSet, setNativeOptionsSet] = React.useState( + new Set() + ) + + // The native `select` only associates the correct default value if the corresponding + // `option` is rendered as a child **at the same time** as itself. + // Because it might take a few renders for our items to gather the information to build + // the native `option`(s), we generate a key on the `select` to make sure React re-builds it + // each time the options change. + const nativeSelectKey = Array.from(nativeOptionsSet) + .map((option) => option.props.value) + .join(";") + + return ( + + setValue(value)} + onItemCheckChange={onItemCheckChange} + open={open} + onOpenChange={setOpen} + dir={direction} + triggerPointerDownPosRef={triggerPointerDownPosRef} + disabled={disabled} + multiple={multiple} + > + + { + setNativeOptionsSet((prev) => new Set(prev).add(option)) + }, [])} + onNativeOptionRemove={React.useCallback((option) => { + setNativeOptionsSet((prev) => { + const optionsSet = new Set(prev) + optionsSet.delete(option) + return optionsSet + }) + }, [])} + > + {children} + + + + {isFormControl ? ( + { + if (multiple) { + setValue( + Array.from(event.currentTarget.selectedOptions).map( + (option) => option.value as T + ) + ) + } else { + setValue(event.target.value as T) + } + }} + disabled={disabled} + form={form} + multiple={multiple} + > + {value === undefined ? + ) : null} + + + ) +} + +Select.displayName = SELECT_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectTrigger + * -----------------------------------------------------------------------------------------------*/ + +const TRIGGER_NAME = "SelectTrigger" + +type SelectTriggerElement = React.ElementRef +type PrimitiveButtonProps = React.ComponentPropsWithoutRef< + typeof Primitive.button +> +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface SelectTriggerProps extends PrimitiveButtonProps {} + +const SelectTrigger = React.forwardRef< + SelectTriggerElement, + SelectTriggerProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeSelect, disabled = false, ...triggerProps } = props + const popperScope = usePopperScope(__scopeSelect) + const context = useSelectContext(TRIGGER_NAME, __scopeSelect) + const isDisabled = context.disabled || disabled + const composedRefs = useComposedRefs(forwardedRef, context.onTriggerChange) + const getItems = useCollection(__scopeSelect) + const pointerTypeRef = + React.useRef("touch") + + const [searchRef, handleTypeaheadSearch, resetTypeahead] = useTypeaheadSearch( + (search) => { + const enabledItems = getItems().filter((item) => !item.disabled) + const currentItem = enabledItems.find( + (item) => item.value === context.value + ) + const nextItem = findNextItem(enabledItems, search, currentItem) + if (nextItem !== undefined) { + context.onValueChange(nextItem.value) + } + } + ) + + const handleOpen = (pointerEvent?: React.MouseEvent | React.PointerEvent) => { + if (!isDisabled) { + context.onOpenChange(true) + // reset typeahead when we open + resetTypeahead() + } + + if (pointerEvent) { + context.triggerPointerDownPosRef.current = { + x: Math.round(pointerEvent.pageX), + y: Math.round(pointerEvent.pageY), + } + } + } + + return ( + + { + // Whilst browsers generally have no issue focusing the trigger when clicking + // on a label, Safari seems to struggle with the fact that there's no `onClick`. + // We force `focus` in this case. Note: this doesn't create any other side-effect + // because we are preventing default in `onPointerDown` so effectively + // this only runs for a label "click" + event.currentTarget.focus() + + // Open on click when using a touch or pen device + if (pointerTypeRef.current !== "mouse") { + handleOpen(event) + } + })} + onPointerDown={composeEventHandlers( + triggerProps.onPointerDown, + (event) => { + pointerTypeRef.current = event.pointerType + + // prevent implicit pointer capture + // https://www.w3.org/TR/pointerevents3/#implicit-pointer-capture + const target = event.target as HTMLElement + if (target.hasPointerCapture(event.pointerId)) { + target.releasePointerCapture(event.pointerId) + } + + // only call handler if it's the left button (mousedown gets triggered by all mouse buttons) + // but not when the control key is pressed (avoiding MacOS right click); also not for touch + // devices because that would open the menu on scroll. (pen devices behave as touch on iOS). + if ( + event.button === 0 && + event.ctrlKey === false && + event.pointerType === "mouse" + ) { + handleOpen(event) + // prevent trigger from stealing focus from the active item after opening. + event.preventDefault() + } + } + )} + onKeyDown={composeEventHandlers(triggerProps.onKeyDown, (event) => { + const isTypingAhead = searchRef.current !== "" + const isModifierKey = event.ctrlKey || event.altKey || event.metaKey + if (!isModifierKey && event.key.length === 1) + handleTypeaheadSearch(event.key) + if (isTypingAhead && event.key === " ") return + if (OPEN_KEYS.includes(event.key)) { + handleOpen() + event.preventDefault() + } + })} + /> + + ) +}) + +SelectTrigger.displayName = TRIGGER_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectValue + * -----------------------------------------------------------------------------------------------*/ + +const VALUE_NAME = "SelectValue" + +type SelectValueElement = React.ElementRef +type PrimitiveSpanProps = React.ComponentPropsWithoutRef +interface SelectValueProps extends Omit { + placeholder?: React.ReactNode +} + +const SelectValue = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + // We ignore `className` and `style` as this part shouldn't be styled. + const { + __scopeSelect, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + className, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + style, + children, + placeholder = "", + ...valueProps + } = props + const context = useSelectContext(VALUE_NAME, __scopeSelect) + const { onValueNodeHasChildrenChange } = context + const hasChildren = children !== undefined + const composedRefs = useComposedRefs( + forwardedRef, + context.onValueNodeChange + ) + + useLayoutEffect(() => { + onValueNodeHasChildrenChange(hasChildren) + }, [onValueNodeHasChildrenChange, hasChildren]) + + return ( + + {shouldShowPlaceholder(context.value) ? <>{placeholder} : children} + + ) + } +) + +SelectValue.displayName = VALUE_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectIcon + * -----------------------------------------------------------------------------------------------*/ + +const ICON_NAME = "SelectIcon" + +type SelectIconElement = React.ElementRef +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface SelectIconProps extends PrimitiveSpanProps {} + +const SelectIcon = React.forwardRef( + (props: ScopedProps, forwardedRef) => { + const { __scopeSelect, children, ...iconProps } = props + return ( + + {children || "▼"} + + ) + } +) + +SelectIcon.displayName = ICON_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectPortal + * -----------------------------------------------------------------------------------------------*/ + +const PORTAL_NAME = "SelectPortal" + +type PortalProps = React.ComponentPropsWithoutRef +interface SelectPortalProps { + children?: React.ReactNode + /** + * Specify a container element to portal the content into. + */ + container?: PortalProps["container"] +} + +const SelectPortal: React.FC = ( + props: ScopedProps +) => { + return +} + +SelectPortal.displayName = PORTAL_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectContent + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_NAME = "SelectContent" + +type SelectContentElement = SelectContentImplElement +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface SelectContentProps extends SelectContentImplProps {} + +const SelectContent = React.forwardRef< + SelectContentElement, + SelectContentProps +>((props: ScopedProps, forwardedRef) => { + const context = useSelectContext(CONTENT_NAME, props.__scopeSelect) + const [fragment, setFragment] = React.useState() + + // setting the fragment in `useLayoutEffect` as `DocumentFragment` doesn't exist on the server + useLayoutEffect(() => { + setFragment(new DocumentFragment()) + }, []) + + if (!context.open) { + const frag = fragment as Element | undefined + return frag + ? ReactDOM.createPortal( + + +

{props.children}
+ + , + frag + ) + : null + } + + return +}) + +SelectContent.displayName = CONTENT_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectContentImpl + * -----------------------------------------------------------------------------------------------*/ + +const CONTENT_MARGIN = 10 + +type SelectContentContextValue = { + content?: SelectContentElement | null + viewport?: SelectViewportElement | null + onViewportChange?: (node: SelectViewportElement | null) => void + itemRefCallback?: ( + node: SelectItemElement | null, + value: string, + disabled: boolean + ) => void + selectedItem?: SelectItemElement | null + onItemLeave?: () => void + itemTextRefCallback?: ( + node: SelectItemTextElement | null, + value: string, + disabled: boolean + ) => void + focusSelectedItem?: () => void + selectedItemText?: SelectItemTextElement | null + position?: SelectContentProps["position"] + isPositioned?: boolean + searchRef?: React.RefObject +} + +const [SelectContentProvider, useSelectContentContext] = + createSelectContext(CONTENT_NAME) + +const CONTENT_IMPL_NAME = "SelectContentImpl" + +type SelectContentImplElement = + | SelectPopperPositionElement + | SelectItemAlignedPositionElement +type DismissableLayerProps = React.ComponentPropsWithoutRef< + typeof DismissableLayer +> +type FocusScopeProps = React.ComponentPropsWithoutRef + +type SelectPopperPrivateProps = { onPlaced?: PopperContentProps["onPlaced"] } + +interface SelectContentImplProps + extends Omit, + Omit { + /** + * Event handler called when auto-focusing on close. + * Can be prevented. + */ + onCloseAutoFocus?: FocusScopeProps["onUnmountAutoFocus"] + /** + * Event handler called when the escape key is down. + * Can be prevented. + */ + onEscapeKeyDown?: DismissableLayerProps["onEscapeKeyDown"] + /** + * Event handler called when the a `pointerdown` event happens outside of the `DismissableLayer`. + * Can be prevented. + */ + onPointerDownOutside?: DismissableLayerProps["onPointerDownOutside"] + + position?: "item-aligned" | "popper" +} + +const Slot = createSlot("SelectContent.RemoveScroll") + +const SelectContentImpl = React.forwardRef< + SelectContentImplElement, + SelectContentImplProps +>((props: ScopedProps, forwardedRef) => { + const { + __scopeSelect, + position = "item-aligned", + onCloseAutoFocus, + onEscapeKeyDown, + onPointerDownOutside, + // + // PopperContent props + side, + sideOffset, + align, + alignOffset, + arrowPadding, + collisionBoundary, + collisionPadding, + sticky, + hideWhenDetached, + avoidCollisions, + // + ...contentProps + } = props + const context = useSelectContext(CONTENT_NAME, __scopeSelect) + const [content, setContent] = React.useState( + null + ) + const [viewport, setViewport] = React.useState( + null + ) + const composedRefs = useComposedRefs(forwardedRef, (node) => setContent(node)) + const [selectedItem, setSelectedItem] = + React.useState(null) + const [selectedItemText, setSelectedItemText] = + React.useState(null) + const getItems = useCollection(__scopeSelect) + const [isPositioned, setIsPositioned] = React.useState(false) + const firstValidItemFoundRef = React.useRef(false) + + // aria-hide everything except the content (better supported equivalent to setting aria-modal) + React.useEffect(() => { + if (content) return hideOthers(content) + }, [content]) + + // Make sure the whole tree has focus guards as our `Select` may be + // the last element in the DOM (because of the `Portal`) + useFocusGuards() + + const focusFirst = React.useCallback( + (candidates: Array) => { + const [firstItem, ...restItems] = getItems().map( + (item) => item.ref.current + ) + const [lastItem] = restItems.slice(-1) + + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement + for (const candidate of candidates) { + // if focus is already where we want to go, we don't want to keep going through the candidates + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return + candidate?.scrollIntoView({ block: "nearest" }) + // viewport might have padding so scroll to its edges when focusing first/last items. + if (candidate === firstItem && viewport) viewport.scrollTop = 0 + if (candidate === lastItem && viewport) + viewport.scrollTop = viewport.scrollHeight + candidate?.focus() + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return + } + }, + [getItems, viewport] + ) + + const focusSelectedItem = React.useCallback( + () => focusFirst([selectedItem, content]), + [focusFirst, selectedItem, content] + ) + + // Since this is not dependent on layout, we want to ensure this runs at the same time as + // other effects across components. Hence why we don't call `focusSelectedItem` inside `position`. + React.useEffect(() => { + if (isPositioned) { + focusSelectedItem() + } + }, [isPositioned, focusSelectedItem]) + + // prevent selecting items on `pointerup` in some cases after opening from `pointerdown` + // and close on `pointerup` outside. + const { onOpenChange, triggerPointerDownPosRef } = context + React.useEffect(() => { + if (content) { + let pointerMoveDelta = { x: 0, y: 0 } + + const handlePointerMove = (event: PointerEvent) => { + pointerMoveDelta = { + x: Math.abs( + Math.round(event.pageX) - (triggerPointerDownPosRef.current?.x ?? 0) + ), + y: Math.abs( + Math.round(event.pageY) - (triggerPointerDownPosRef.current?.y ?? 0) + ), + } + } + const handlePointerUp = (event: PointerEvent) => { + // If the pointer hasn't moved by a certain threshold then we prevent selecting item on `pointerup`. + if (pointerMoveDelta.x <= 10 && pointerMoveDelta.y <= 10) { + event.preventDefault() + } else { + // otherwise, if the event was outside the content, close. + if (!content.contains(event.target as HTMLElement)) { + onOpenChange(false) + } + } + document.removeEventListener("pointermove", handlePointerMove) + triggerPointerDownPosRef.current = null + } + + if (triggerPointerDownPosRef.current !== null) { + document.addEventListener("pointermove", handlePointerMove) + document.addEventListener("pointerup", handlePointerUp, { + capture: true, + once: true, + }) + } + + return () => { + document.removeEventListener("pointermove", handlePointerMove) + document.removeEventListener("pointerup", handlePointerUp, { + capture: true, + }) + } + } + }, [content, onOpenChange, triggerPointerDownPosRef]) + + React.useEffect(() => { + const close = () => onOpenChange(false) + window.addEventListener("blur", close) + window.addEventListener("resize", close) + return () => { + window.removeEventListener("blur", close) + window.removeEventListener("resize", close) + } + }, [onOpenChange]) + + const [searchRef, handleTypeaheadSearch] = useTypeaheadSearch((search) => { + const enabledItems = getItems().filter((item) => !item.disabled) + const currentItem = enabledItems.find( + (item) => item.ref.current === document.activeElement + ) + const nextItem = findNextItem(enabledItems, search, currentItem) + if (nextItem) { + /** + * Imperative focus during keydown is risky so we prevent React's batching updates + * to avoid potential bugs. See: https://github.com/facebook/react/issues/20332 + */ + setTimeout(() => (nextItem.ref.current as HTMLElement).focus()) + } + }) + + const itemRefCallback = React.useCallback( + (node: SelectItemElement | null, value: string, disabled: boolean) => { + const contextValueArray = ( + Array.isArray(context.value) ? context.value : [context.value] + ).filter((item) => item !== undefined) + + const isFirstValidItem = !firstValidItemFoundRef.current && !disabled + const isSelectedItem = + context.value !== undefined && contextValueArray.includes(value) + if (isSelectedItem || isFirstValidItem) { + setSelectedItem(node) + if (isFirstValidItem) firstValidItemFoundRef.current = true + } + }, + [context.value] + ) + const handleItemLeave = React.useCallback(() => content?.focus(), [content]) + const itemTextRefCallback = React.useCallback( + (node: SelectItemTextElement | null, value: string, disabled: boolean) => { + const isFirstValidItem = !firstValidItemFoundRef.current && !disabled + const isSelectedItem = + context.value !== undefined && context.value === value + if (isSelectedItem || isFirstValidItem) { + setSelectedItemText(node) + } + }, + [context.value] + ) + + const SelectPosition = + position === "popper" ? SelectPopperPosition : SelectItemAlignedPosition + + // Silently ignore props that are not supported by `SelectItemAlignedPosition` + const popperContentProps = + SelectPosition === SelectPopperPosition + ? { + side, + sideOffset, + align, + alignOffset, + arrowPadding, + collisionBoundary, + collisionPadding, + sticky, + hideWhenDetached, + avoidCollisions, + } + : {} + + return ( + + + { + // we prevent open autofocus because we manually focus the selected item + event.preventDefault() + }} + onUnmountAutoFocus={composeEventHandlers( + onCloseAutoFocus, + (event) => { + context.trigger?.focus({ preventScroll: true }) + event.preventDefault() + } + )} + > + event.preventDefault()} + onDismiss={() => context.onOpenChange(false)} + > + event.preventDefault()} + {...contentProps} + {...popperContentProps} + onPlaced={() => setIsPositioned(true)} + ref={composedRefs} + style={{ + // flex layout so we can place the scroll buttons properly + display: "flex", + flexDirection: "column", + // reset the outline by default as the content MAY get focused + outline: "none", + ...contentProps.style, + }} + onKeyDown={composeEventHandlers( + contentProps.onKeyDown, + (event) => { + const isModifierKey = + event.ctrlKey || event.altKey || event.metaKey + + // select should not be navigated using tab key so we prevent it + if (event.key === "Tab") event.preventDefault() + + if (!isModifierKey && event.key.length === 1) + handleTypeaheadSearch(event.key) + + if ( + ["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key) + ) { + const items = getItems().filter((item) => !item.disabled) + let candidateNodes = items.map((item) => item.ref.current!) + + if (["ArrowUp", "End"].includes(event.key)) { + candidateNodes = candidateNodes.slice().reverse() + } + if (["ArrowUp", "ArrowDown"].includes(event.key)) { + const currentElement = event.target as SelectItemElement + const currentIndex = + candidateNodes.indexOf(currentElement) + candidateNodes = candidateNodes.slice(currentIndex + 1) + } + + /** + * Imperative focus during keydown is risky so we prevent React's batching updates + * to avoid potential bugs. See: https://github.com/facebook/react/issues/20332 + */ + setTimeout(() => focusFirst(candidateNodes)) + + event.preventDefault() + } + } + )} + /> + + + + + ) +}) + +SelectContentImpl.displayName = CONTENT_IMPL_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectItemAlignedPosition + * -----------------------------------------------------------------------------------------------*/ + +const ITEM_ALIGNED_POSITION_NAME = "SelectItemAlignedPosition" + +type SelectItemAlignedPositionElement = React.ElementRef +interface SelectItemAlignedPositionProps + extends PrimitiveDivProps, + SelectPopperPrivateProps {} + +const SelectItemAlignedPosition = React.forwardRef< + SelectItemAlignedPositionElement, + SelectItemAlignedPositionProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeSelect, onPlaced, ...popperProps } = props + const context = useSelectContext(CONTENT_NAME, __scopeSelect) + const contentContext = useSelectContentContext(CONTENT_NAME, __scopeSelect) + const [contentWrapper, setContentWrapper] = + React.useState(null) + const [content, setContent] = + React.useState(null) + const composedRefs = useComposedRefs(forwardedRef, (node) => setContent(node)) + const getItems = useCollection(__scopeSelect) + const shouldExpandOnScrollRef = React.useRef(false) + const shouldRepositionRef = React.useRef(true) + + const { viewport, selectedItem, selectedItemText, focusSelectedItem } = + contentContext + const position = React.useCallback(() => { + if ( + context.trigger && + context.valueNode && + contentWrapper && + content && + viewport && + selectedItem && + selectedItemText + ) { + const triggerRect = context.trigger.getBoundingClientRect() + + // ----------------------------------------------------------------------------------------- + // Horizontal positioning + // ----------------------------------------------------------------------------------------- + const contentRect = content.getBoundingClientRect() + const valueNodeRect = context.valueNode.getBoundingClientRect() + const itemTextRect = selectedItemText.getBoundingClientRect() + + if (context.dir !== "rtl") { + const itemTextOffset = itemTextRect.left - contentRect.left + const left = valueNodeRect.left - itemTextOffset + const leftDelta = triggerRect.left - left + const minContentWidth = triggerRect.width + leftDelta + const contentWidth = Math.max(minContentWidth, contentRect.width) + const rightEdge = window.innerWidth - CONTENT_MARGIN + const clampedLeft = clamp(left, [ + CONTENT_MARGIN, + // Prevents the content from going off the starting edge of the + // viewport. It may still go off the ending edge, but this can be + // controlled by the user since they may want to manage overflow in a + // specific way. + // https://github.com/radix-ui/primitives/issues/2049 + Math.max(CONTENT_MARGIN, rightEdge - contentWidth), + ]) + + contentWrapper.style.minWidth = minContentWidth + "px" + contentWrapper.style.left = clampedLeft + "px" + } else { + const itemTextOffset = contentRect.right - itemTextRect.right + const right = window.innerWidth - valueNodeRect.right - itemTextOffset + const rightDelta = window.innerWidth - triggerRect.right - right + const minContentWidth = triggerRect.width + rightDelta + const contentWidth = Math.max(minContentWidth, contentRect.width) + const leftEdge = window.innerWidth - CONTENT_MARGIN + const clampedRight = clamp(right, [ + CONTENT_MARGIN, + Math.max(CONTENT_MARGIN, leftEdge - contentWidth), + ]) + + contentWrapper.style.minWidth = minContentWidth + "px" + contentWrapper.style.right = clampedRight + "px" + } + + // ----------------------------------------------------------------------------------------- + // Vertical positioning + // ----------------------------------------------------------------------------------------- + const items = getItems() + const availableHeight = window.innerHeight - CONTENT_MARGIN * 2 + const itemsHeight = viewport.scrollHeight + + const contentStyles = window.getComputedStyle(content) + const contentBorderTopWidth = parseInt(contentStyles.borderTopWidth, 10) + const contentPaddingTop = parseInt(contentStyles.paddingTop, 10) + const contentBorderBottomWidth = parseInt( + contentStyles.borderBottomWidth, + 10 + ) + const contentPaddingBottom = parseInt(contentStyles.paddingBottom, 10) + const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth; // prettier-ignore + const minContentHeight = Math.min( + selectedItem.offsetHeight * 5, + fullContentHeight + ) + + const viewportStyles = window.getComputedStyle(viewport) + const viewportPaddingTop = parseInt(viewportStyles.paddingTop, 10) + const viewportPaddingBottom = parseInt(viewportStyles.paddingBottom, 10) + + const topEdgeToTriggerMiddle = + triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN + const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle + + const selectedItemHalfHeight = selectedItem.offsetHeight / 2 + const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight + const contentTopToItemMiddle = + contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle + const itemMiddleToContentBottom = + fullContentHeight - contentTopToItemMiddle + + const willAlignWithoutTopOverflow = + contentTopToItemMiddle <= topEdgeToTriggerMiddle + + if (willAlignWithoutTopOverflow) { + const isLastItem = + items.length > 0 && + selectedItem === items[items.length - 1]!.ref.current + contentWrapper.style.bottom = 0 + "px" + const viewportOffsetBottom = + content.clientHeight - viewport.offsetTop - viewport.offsetHeight + const clampedTriggerMiddleToBottomEdge = Math.max( + triggerMiddleToBottomEdge, + selectedItemHalfHeight + + // viewport might have padding bottom, include it to avoid a scrollable viewport + (isLastItem ? viewportPaddingBottom : 0) + + viewportOffsetBottom + + contentBorderBottomWidth + ) + const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge + contentWrapper.style.height = height + "px" + } else { + const isFirstItem = + items.length > 0 && selectedItem === items[0]!.ref.current + contentWrapper.style.top = 0 + "px" + const clampedTopEdgeToTriggerMiddle = Math.max( + topEdgeToTriggerMiddle, + contentBorderTopWidth + + viewport.offsetTop + + // viewport might have padding top, include it to avoid a scrollable viewport + (isFirstItem ? viewportPaddingTop : 0) + + selectedItemHalfHeight + ) + const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom + contentWrapper.style.height = height + "px" + viewport.scrollTop = + contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop + } + + contentWrapper.style.margin = `${CONTENT_MARGIN}px 0` + contentWrapper.style.minHeight = minContentHeight + "px" + contentWrapper.style.maxHeight = availableHeight + "px" + // ----------------------------------------------------------------------------------------- + + onPlaced?.() + + // we don't want the initial scroll position adjustment to trigger "expand on scroll" + // so we explicitly turn it on only after they've registered. + requestAnimationFrame(() => (shouldExpandOnScrollRef.current = true)) + } + }, [ + getItems, + context.trigger, + context.valueNode, + contentWrapper, + content, + viewport, + selectedItem, + selectedItemText, + context.dir, + onPlaced, + ]) + + useLayoutEffect(() => position(), [position]) + + // copy z-index from content to wrapper + const [contentZIndex, setContentZIndex] = React.useState() + useLayoutEffect(() => { + if (content) setContentZIndex(window.getComputedStyle(content).zIndex) + }, [content]) + + // When the viewport becomes scrollable at the top, the scroll up button will mount. + // Because it is part of the normal flow, it will push down the viewport, thus throwing our + // trigger => selectedItem alignment off by the amount the viewport was pushed down. + // We wait for this to happen and then re-run the positining logic one more time to account for it. + const handleScrollButtonChange = React.useCallback( + (node: SelectScrollButtonImplElement | null) => { + if (node && shouldRepositionRef.current === true) { + position() + focusSelectedItem?.() + shouldRepositionRef.current = false + } + }, + [position, focusSelectedItem] + ) + + return ( + +
+ +
+
+ ) +}) + +SelectItemAlignedPosition.displayName = ITEM_ALIGNED_POSITION_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectPopperPosition + * -----------------------------------------------------------------------------------------------*/ + +const POPPER_POSITION_NAME = "SelectPopperPosition" + +type SelectPopperPositionElement = React.ElementRef< + typeof PopperPrimitive.Content +> +type PopperContentProps = React.ComponentPropsWithoutRef< + typeof PopperPrimitive.Content +> +interface SelectPopperPositionProps + extends PopperContentProps, + SelectPopperPrivateProps {} + +const SelectPopperPosition = React.forwardRef< + SelectPopperPositionElement, + SelectPopperPositionProps +>((props: ScopedProps, forwardedRef) => { + const { + __scopeSelect, + align = "start", + collisionPadding = CONTENT_MARGIN, + ...popperProps + } = props + const popperScope = usePopperScope(__scopeSelect) + + return ( + + ) +}) + +SelectPopperPosition.displayName = POPPER_POSITION_NAME + +/* ------------------------------------------------------------------------------------------------- + * SelectViewport + * -----------------------------------------------------------------------------------------------*/ + +type SelectViewportContextValue = { + contentWrapper?: HTMLDivElement | null + shouldExpandOnScrollRef?: React.RefObject + onScrollButtonChange?: (node: SelectScrollButtonImplElement | null) => void +} + +const [SelectViewportProvider, useSelectViewportContext] = + createSelectContext(CONTENT_NAME, {}) + +const VIEWPORT_NAME = "SelectViewport" + +type SelectViewportElement = React.ElementRef +type PrimitiveDivProps = React.ComponentPropsWithoutRef +interface SelectViewportProps extends PrimitiveDivProps { + nonce?: string +} + +const SelectViewport = React.forwardRef< + SelectViewportElement, + SelectViewportProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeSelect, nonce, ...viewportProps } = props + const contentContext = useSelectContentContext(VIEWPORT_NAME, __scopeSelect) + const viewportContext = useSelectViewportContext(VIEWPORT_NAME, __scopeSelect) + const composedRefs = useComposedRefs( + forwardedRef, + contentContext.onViewportChange + ) + const prevScrollTopRef = React.useRef(0) + return ( + <> + {/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */} +