diff --git a/packages/react/.storybook/a11y-skip-allowlist.json b/packages/react/.storybook/a11y-skip-allowlist.json index d92047d6b2..e3b46ba8f8 100644 --- a/packages/react/.storybook/a11y-skip-allowlist.json +++ b/packages/react/.storybook/a11y-skip-allowlist.json @@ -5,7 +5,6 @@ "src/components/F0ButtonToggle/__stories__/F0ButtonToggle.stories.tsx": 1, "src/components/F0DatePicker/__stories__/F0DatePicker.stories.tsx": 1, "src/components/F0InputField/__stories__/F0InputField.stories.tsx": 1, - "src/components/F0Select/__stories__/F0Select.stories.tsx": 2, "src/components/F0TextAreaInput/__stories__/F0TextAreaInput.stories.tsx": 1, "src/components/F0TextInput/__stories__/F0TextInput.stories.tsx": 1, "src/components/OneCalendar/OneCalendar.stories.tsx": 2, diff --git a/packages/react/src/components/F0SearchInput/F0SearchInput.tsx b/packages/react/src/components/F0SearchInput/F0SearchInput.tsx index 2d1856fbdd..68f9050659 100644 --- a/packages/react/src/components/F0SearchInput/F0SearchInput.tsx +++ b/packages/react/src/components/F0SearchInput/F0SearchInput.tsx @@ -45,28 +45,39 @@ const F0SearchInput = forwardRef( ) => { const input = useRef(null) - const interval = useRef(null) - useImperativeHandle(ref, () => input.current as HTMLInputElement) useEffect(() => { - if (!props.autoFocus) { - if (interval.current) { - clearInterval(interval.current) - } + const element = input.current + + if ( + !props.autoFocus || + props.disabled || + !element || + document.activeElement === element + ) { return } - interval.current = setInterval(() => { - input.current?.focus() + let timeout: ReturnType | undefined + const stopAutoFocus = () => { + if (timeout !== undefined) { + clearTimeout(timeout) + timeout = undefined + } + element.removeEventListener("focus", stopAutoFocus) + } + + element.addEventListener("focus", stopAutoFocus) + timeout = setTimeout(() => { + element.focus() + stopAutoFocus() }, 50) return () => { - if (interval.current) { - clearInterval(interval.current) - } + stopAutoFocus() } - }, [props.autoFocus]) + }, [props.autoFocus, props.disabled]) const valueToEmitRef = useRef(undefined) @@ -81,8 +92,12 @@ const F0SearchInput = forwardRef( if (valueToEmitRef.current === undefined) { setTimeout(() => { if (valueToEmitRef.current !== undefined) { + const shouldRestoreFocus = + document.activeElement === input.current onChange(valueToEmitRef.current) - input.current?.focus() + if (shouldRestoreFocus) { + input.current?.focus() + } } valueToEmitRef.current = undefined }, debounceTime) @@ -108,7 +123,6 @@ const F0SearchInput = forwardRef( onChange={onChangeLocal} role="searchbox" size={size} - autoFocus={props.autoFocus} clearable={clearable} onBlur={onBlur} onFocus={onFocus} diff --git a/packages/react/src/components/F0SearchInput/__tests__/F0SearchInput.test.tsx b/packages/react/src/components/F0SearchInput/__tests__/F0SearchInput.test.tsx index fe40501280..eb488b0c8d 100644 --- a/packages/react/src/components/F0SearchInput/__tests__/F0SearchInput.test.tsx +++ b/packages/react/src/components/F0SearchInput/__tests__/F0SearchInput.test.tsx @@ -1,5 +1,11 @@ -import { act, fireEvent, render, screen } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + act, + fireEvent, + screen, + zeroRender as render, +} from "@/testing/test-utils" import { F0SearchInput } from "../index" @@ -8,6 +14,60 @@ describe("F0SearchInput", () => { vi.useFakeTimers() }) + afterEach(() => { + vi.useRealTimers() + }) + + describe("autofocus behavior", () => { + it("focuses once without reclaiming focus after navigation", () => { + const onChange = vi.fn() + render( + <> + + + + ) + + const input = screen.getByRole("searchbox") + const nextButton = screen.getByRole("button", { name: "Next" }) + + act(() => { + vi.advanceTimersByTime(50) + }) + expect(input).toHaveFocus() + + fireEvent.change(input, { target: { value: "query" } }) + nextButton.focus() + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onChange).toHaveBeenCalledWith("query") + expect(nextButton).toHaveFocus() + }) + + it("cancels a pending retry after the input receives focus", () => { + render( + <> + + + + ) + + const input = screen.getByRole("searchbox") + const nextButton = screen.getByRole("button", { name: "Next" }) + + input.focus() + nextButton.focus() + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(nextButton).toHaveFocus() + }) + }) + describe("threshold behavior", () => { it("does not trigger onChange when input length is below threshold", () => { const onChange = vi.fn() diff --git a/packages/react/src/components/F0Select/F0Select.tsx b/packages/react/src/components/F0Select/F0Select.tsx index 3339596f3c..b74937e19b 100644 --- a/packages/react/src/components/F0Select/F0Select.tsx +++ b/packages/react/src/components/F0Select/F0Select.tsx @@ -1,4 +1,5 @@ import { useDeepCompareEffect } from "@reactuses/core" +import { useComposedRefs } from "@radix-ui/react-compose-refs" import { cva } from "cva" import { isEqual } from "lodash" import { @@ -12,9 +13,11 @@ import { useState, } from "react" -import { F0DialogContext } from "@/patterns/F0Dialog" import { F0Button } from "@/components/F0Button" -import { Plus } from "@/icons/app" +import { F0Icon } from "@/components/F0Icon" +import { F0InputField } from "@/components/F0InputField" +import { InputMessages } from "@/components/F0InputField/components/InputMessages" +import { Label } from "@/components/F0InputField/components/Label" import { BaseFetchOptions, BaseResponse, @@ -29,14 +32,13 @@ import { useSelectable, WithGroupId, } from "@/hooks/datasource" +import { ChevronDown, Plus } from "@/icons/app" import { DataTestIdWrapper } from "@/lib/data-testid" import { useI18n } from "@/lib/providers/i18n" import { toArray } from "@/lib/toArray" -import { cn } from "@/lib/utils" +import { cn, focusRing } from "@/lib/utils" +import { F0DialogContext } from "@/patterns/F0Dialog" import { GroupHeader } from "@/ui/GroupHeader/index" -import { F0InputField } from "@/components/F0InputField" -import { InputMessages } from "@/components/F0InputField/components/InputMessages" -import { Label } from "@/components/F0InputField/components/Label" import { SelectContent, Select as SelectPrimitive, @@ -44,6 +46,7 @@ import { SelectTrigger, VirtualItem, } from "@/ui/Select" +import { textVariants } from "@/ui/Text" import type { F0SelectItemObject, @@ -109,11 +112,58 @@ const asListContainerVariants = cva({ }, }) +const inlineSelectTriggerClassName = cn( + "group inline-flex h-8 w-fit max-w-full items-center gap-1 rounded border-0 bg-transparent pl-3 pr-2 shadow-none outline-none transition-colors enabled:cursor-pointer enabled:hover:bg-f1-background-hover data-[state=open]:bg-f1-background-hover disabled:cursor-not-allowed disabled:bg-f1-background-tertiary disabled:text-f1-foreground-disabled disabled:data-[state=open]:bg-f1-background-tertiary disabled:[&_*]:text-f1-foreground-disabled", + textVariants({ variant: "label" }) +) + +type InlineSelectTriggerProps = { + label: string + placeholder?: string + selection: F0SelectItemObject[] + hasValue: boolean +} + +const InlineSelectTrigger = forwardRef< + HTMLButtonElement, + InlineSelectTriggerProps +>(function InlineSelectTrigger( + { label, placeholder, selection, hasValue }, + ref +) { + return ( + + + {hasValue ? ( + + ) : ( + + {placeholder ?? label} + + )} + + + + ) +}) + +InlineSelectTrigger.displayName = "InlineSelectTrigger" + const F0SelectComponent = forwardRef(function Select< T extends string, R = unknown, >( { + variant = "field", placeholder, onChange, withApplySelection = false, @@ -131,7 +181,7 @@ const F0SelectComponent = forwardRef(function Select< onSearchChange, searchBoxPlaceholder, searchEmptyMessage, - size = "sm", + size: sizeProp, actions, onCreate, onFiltersChange, @@ -151,13 +201,14 @@ const F0SelectComponent = forwardRef(function Select< asList = false, showPreview = false, preserveSelectionOnDatasetChange = true, - fitContentWidth = false, + fitContentWidth, dataTestId, ...props }: F0SelectProps, ref: React.ForwardedRef ) { const id = useId() + const size = sizeProp ?? "sm" // If inside a OneDialog and no portalContainer is provided, use the dialog's container // only for center/fullscreen dialogs (which have focus trap). @@ -183,8 +234,18 @@ const F0SelectComponent = forwardRef(function Select< type ActualRecordType = ResolvedRecordType const [openLocal, setOpenLocal] = useState(open) + const inlineTriggerRef = useRef(null) + const composedTriggerRef = useComposedRefs(ref, inlineTriggerRef) + const previousOpenRef = useRef(openLocal) const isApplyingRef = useRef(false) + useEffect(() => { + if (variant === "inline" && previousOpenRef.current && !openLocal) { + inlineTriggerRef.current?.focus({ preventScroll: true }) + } + previousOpenRef.current = openLocal + }, [openLocal, variant]) + const defaultItems = useMemo( () => toArray(props.defaultItem).filter( @@ -205,6 +266,10 @@ const F0SelectComponent = forwardRef(function Select< const initial = toArray(value) ?? defaultValues ?? [] return initial.map(String) }) + const controlledInlineValue = + variant === "inline" && typeof value === "string" + ? String(value) + : undefined useEffect(() => { const incomingValues = (toArray(value) ?? []).map(String) @@ -504,6 +569,7 @@ const F0SelectComponent = forwardRef(function Select< ) }, [data.records, optionMapper, getDisplayItemsForSelection]) const effectiveSize = hasStatusTag ? "md" : size + const effectiveFitContentWidth = fitContentWidth ?? variant === "inline" const onSearchChangeLocal = (value: string) => { setCurrentSearch(value) @@ -734,9 +800,26 @@ const F0SelectComponent = forwardRef(function Select< // from deferred-apply back to immediate-emit isn't suppressed. lastEmittedSingleRef.current = { value: valueKey } onChange?.(value as T, originalItem, option) + + // A controlled inline select must keep the prop as its source of truth. + // The selection hook updates optimistically so `onChange` can be emitted; + // if the parent leaves `value` unchanged, restore both the selection + // state and the primitive value after that emission. Resetting the + // emission guard also allows the user to retry the same rejected value. + if ( + controlledInlineValue !== undefined && + valueKey !== controlledInlineValue + ) { + hasUserInteracted.current = false + lastEmittedSingleRef.current = null + clearSelection() + handleSelectItemChange(controlledInlineValue, true) + setLocalValue([controlledInlineValue]) + } } } }, [ + controlledInlineValue, getMultiSelectionPayload, hasDeferredApply, optionMapper, @@ -1088,7 +1171,7 @@ const F0SelectComponent = forwardRef(function Select< const selectContent = ( - - {children ? ( -
- {children} -
- ) : ( - - multiple ? !value || +(value ?? 0) === 0 : !value - } - onClear={() => { - hasUserInteracted.current = true - clearSelection() - // Clear the cache when clearing selection - selectedItemsCache.current.clear() - // Call with undefined to indicate no item is selected - ;( - onChangeSelectedOption as ( - option: undefined, - checked: boolean - ) => void - )?.(undefined, false) - }} - placeholder={placeholder || ""} - disabled={disabled} - clearable={clearable} - size={effectiveSize} - loadingIndicator={{ - asOverlay: true, - offset: 34, - }} - loading={isInitialLoading || loading || isLoading} - name={name} - onClickContent={() => { - handleChangeOpenLocal(!openLocal) - }} - append={ - - } - > - - - )} -
+ } + > + + + )} + + )} {openLocal && selectContent} ) return ( - {withTriggerTooltip(triggerWithContent)} + {variant === "inline" + ? triggerWithContent + : withTriggerTooltip(triggerWithContent)} ) }) diff --git a/packages/react/src/components/F0Select/__stories__/F0Select.inline.stories.tsx b/packages/react/src/components/F0Select/__stories__/F0Select.inline.stories.tsx new file mode 100644 index 0000000000..36c005755f --- /dev/null +++ b/packages/react/src/components/F0Select/__stories__/F0Select.inline.stories.tsx @@ -0,0 +1,276 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { useState } from "react" +import { expect, fn, userEvent, waitFor, within } from "storybook/test" + +import { withSnapshot } from "@/lib/storybook-utils/parameters" + +import { F0Select, type F0SelectItemProps, type F0SelectProps } from "../index" + +type Role = "owner" | "editor" | "viewer" + +const roleOptions: F0SelectItemProps[] = [ + { + value: "owner", + label: "Owner", + description: "Can manage access and change roles", + }, + { + value: "editor", + label: "Editor", + description: "Can view and edit this policy", + }, + { + value: "viewer", + label: "Viewer", + description: "Can view this policy", + }, +] + +const longRoleOptions: F0SelectItemProps[] = roleOptions.map((option) => + option.type === "separator" || option.value !== "viewer" + ? option + : { + ...option, + label: "Viewer with a deliberately long access-level label", + } +) + +type InlineRoleSelectProps = { + value?: Role + options?: F0SelectItemProps[] + label?: string + placeholder?: string + disabled?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + onChange?: (value: Role) => void + fitContentWidth?: boolean + actions?: F0SelectProps["actions"] + portalContainer?: HTMLElement | null +} + +function InlineRoleSelect({ + value: initialValue, + options = roleOptions, + label = "Access level", + placeholder = "Select role", + onChange, + ...props +}: InlineRoleSelectProps) { + const [value, setValue] = useState(initialValue) + + return ( + { + setValue(nextValue) + onChange?.(nextValue) + }} + /> + ) +} + +function OpenInlineRoleSelect(props: InlineRoleSelectProps) { + const [portalContainer, setPortalContainer] = useState( + null + ) + + return ( +
+ +
+ ) +} + +const removeAccessAction = { + label: "Remove access", + variant: "critical" as const, + onClick: fn(), +} + +const meta = { + title: "Select/Inline", + component: InlineRoleSelect, + parameters: { + layout: "centered", + a11y: { + test: "error", + }, + docs: { + description: { + component: + "Use the inline F0Select variant for compact single-value controls embedded in desktop rows, such as roles, statuses, and access levels. It is borderless, non-clearable, and does not support multiple selection, list mode, preview/apply behavior, custom triggers, or field validation props. Its required label provides the accessible name and becomes the visible empty-state fallback when no placeholder is provided. The popup keeps the standard F0Select density and behavior.", + }, + }, + }, + tags: ["experimental", "!autodocs"], + args: { + label: "Access level", + placeholder: "Select role", + onChange: fn(), + actions: [removeAccessAction], + }, + argTypes: { + fitContentWidth: { + control: "boolean", + description: + "Defaults to true for inline selects. Set false to restore the standard 20rem popup minimum.", + table: { defaultValue: { summary: "true" } }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ViewerSelected: Story = { + args: { + value: "viewer", + }, + play: async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement) + const page = within(canvasElement.closest("body")!) + const trigger = canvas.getByRole("combobox", { name: "Access level" }) + + await step("Expose the initial combobox state", async () => { + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await waitFor(() => { + expect(canvas.getByText("Viewer")).toBeInTheDocument() + }) + }) + + await step("Open from the keyboard and navigate to Editor", async () => { + trigger.focus() + await userEvent.keyboard("{Enter}") + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "true") + }) + await waitFor(() => { + expect(page.getByRole("listbox")).toBeInTheDocument() + }) + await waitFor(() => { + expect(page.getByRole("option", { name: /Viewer/ })).toHaveFocus() + }) + + await userEvent.keyboard("{ArrowUp}") + await waitFor(() => { + expect(page.getByRole("option", { name: /Editor/ })).toHaveFocus() + }) + }) + + await step("Select the focused role", async () => { + await userEvent.keyboard("{Enter}") + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "false") + expect(canvas.getByText("Editor")).toBeInTheDocument() + }) + await expect(args.onChange).toHaveBeenCalledWith("editor") + }) + + await step("Close with Escape and restore trigger focus", async () => { + trigger.focus() + await userEvent.keyboard("{Enter}") + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "true") + }) + + await userEvent.keyboard("{Escape}") + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "false") + }) + await waitFor(() => { + expect(trigger).toHaveFocus() + }) + }) + }, +} + +export const EmptyPlaceholder: Story = {} + +export const Disabled: Story = { + args: { + value: "viewer", + disabled: true, + }, +} + +export const LongLabel: Story = { + args: { + value: "viewer", + options: longRoleOptions, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const Open: Story = { + args: { + value: "viewer", + }, + // The shared popup currently aria-hides its focusable trigger. Keep axe + // running and surface that existing aria-hidden-focus debt as non-blocking. + parameters: { + a11y: { test: "todo" }, + }, + render: (args) => , +} + +export const DarkMode: Story = { + args: { + value: "viewer", + }, + render: (args) => ( +
+
+ Enabled + +
+
+ Disabled + +
+
+ ), +} + +export const Snapshot: Story = { + tags: ["no-sidebar"], + args: {}, + parameters: withSnapshot({ a11y: { test: "todo" } }), + render: () => ( +
+ + + +
+ +
+
+
+ Enabled + +
+
+ Disabled + +
+
+ +
+ ), +} diff --git a/packages/react/src/components/F0Select/__stories__/F0Select.stories.tsx b/packages/react/src/components/F0Select/__stories__/F0Select.stories.tsx index 27a08659bf..c7f210af94 100644 --- a/packages/react/src/components/F0Select/__stories__/F0Select.stories.tsx +++ b/packages/react/src/components/F0Select/__stories__/F0Select.stories.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import { expect, fn, within } from "storybook/test" import { IconType } from "@/components/F0Icon" +import { inputFieldStatus } from "@/components/F0InputField" import { createDataSourceDefinition, FiltersDefinition, @@ -12,10 +13,9 @@ import { import { SelectedItemsDetailedStatus } from "@/hooks/datasource/types/selection.typings" import { Appearance, Circle, Desktop, Placeholder, Plus } from "@/icons/app" import { dataTestIdArgs } from "@/lib/data-testid/__stories__/args" -import { withSkipA11y, withSnapshot } from "@/lib/storybook-utils/parameters" -import { inputFieldStatus } from "@/components/F0InputField" +import { withSnapshot } from "@/lib/storybook-utils/parameters" -import { F0Select, selectSizes } from "../index" +import { F0Select, selectSizes, selectVariants } from "../index" import { Employee, employeeNestedPaginatedSource, @@ -56,18 +56,29 @@ const meta: Meta = { component: F0Select, parameters: { a11y: { - skipCi: true, + test: "todo", }, docs: { description: { component: - "

Renders an select input field with a list of options to choose from.

" + - "

The list is virtualized so can handle large amount of items

" + + "

Renders a select input field with a list of options to choose from.

" + + "

The list is virtualized so it can handle a large number of items.

" + + '

Use variant="field" for forms and labeled inputs. Use variant="inline" for compact desktop row controls such as roles, statuses, and access levels. Inline selects are single-value and non-clearable; their required label provides the accessible name and becomes the visible empty-state fallback when no placeholder is provided.

' + "

Options support three kinds of annotations: description for prose rendered as a second line, metadata for a short typed token rendered next to the label (e.g. a dial code), and tag for chips rendered at the end of the row.

", }, }, }, argTypes: { + variant: { + control: "radio", + options: selectVariants, + description: + "Field renders the standard form control. Inline renders a compact, borderless single-value row control and does not support clearing, multiple selection, list mode, preview/apply behavior, custom triggers, or field validation props.", + table: { + type: { summary: selectVariants.join(" | ") }, + defaultValue: { summary: "field" }, + }, + }, label: { description: "Label of the select", required: true, @@ -83,9 +94,11 @@ const meta: Meta = { }, size: { control: "select", - options: ["sm", "md"], - defaultValue: "sm", - description: "Size of the select", + options: selectSizes, + if: { arg: "variant", neq: "inline" }, + description: + "Size of the field select. Inline selects use a fixed 32px trigger.", + table: { defaultValue: { summary: "sm" } }, }, disabled: { control: "boolean", @@ -197,6 +210,7 @@ const meta: Meta = { " onClick: () => void\n" + " icon?: IconType\n" + " variant?: 'ghost' | 'critical'\n" + + " disabled?: boolean\n" + "}```", }, loading: { @@ -1208,7 +1222,7 @@ export const MultiplePaginatedAsList: Story = { }, render: (args) => { return ( -
+
) @@ -1483,7 +1497,11 @@ export const WithOnCreate: Story = { } export const Snapshot: Story = { - parameters: withSkipA11y(withSnapshot({})), + parameters: withSnapshot({ + a11y: { + test: "error", + }, + }), args: { label: "Label text here", }, @@ -1496,38 +1514,52 @@ export const Snapshot: Story = { label: "Label text here", } const snapshotVariants = [ - { ...base }, - { ...base, disabled: true }, - { ...base, readonly: true }, - { ...base, required: true }, - { ...base, hideLabel: true }, - { ...base, error: true }, - { ...base, status: { type: "error" as const, message: "Error message" } }, + { name: "Default", props: { ...base } }, + { name: "Disabled", props: { ...base, disabled: true } }, + { name: "Required", props: { ...base, required: true } }, + { name: "Hidden label", props: { ...base, hideLabel: true } }, + { name: "Legacy error", props: { ...base, error: "Error message" } }, + { + name: "Error status", + props: { + ...base, + status: { type: "error" as const, message: "Error message" }, + }, + }, { - ...base, - status: { type: "warning" as const, message: "Warning message" }, + name: "Warning status", + props: { + ...base, + status: { type: "warning" as const, message: "Warning message" }, + }, + }, + { + name: "Info status", + props: { + ...base, + status: { type: "info" as const, message: "Info message" }, + }, }, - { ...base, status: { type: "info" as const, message: "Info message" } }, - { ...base, hint: "Hint message" }, - { ...base }, + { name: "Hint", props: { ...base, hint: "Hint message" } }, ] return (
{selectSizes.map((size) => (
-

Size: {size}

+

Size: {size}

- {snapshotVariants.map((variant, index) => ( + {snapshotVariants.map((variant) => ( diff --git a/packages/react/src/components/F0Select/__tests__/F0Select.test.tsx b/packages/react/src/components/F0Select/__tests__/F0Select.test.tsx index a2e1e58e14..e613d09c69 100644 --- a/packages/react/src/components/F0Select/__tests__/F0Select.test.tsx +++ b/packages/react/src/components/F0Select/__tests__/F0Select.test.tsx @@ -1,12 +1,13 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import "@testing-library/jest-dom/vitest" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { createRef, useState } from "react" +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest" import { createDataSourceDefinition, type RecordType } from "@/hooks/datasource" import { zeroRender as render } from "@/testing/test-utils" -import type { F0SelectItemProps } from "../types" +import type { F0SelectItemProps, F0SelectProps } from "../types" import { Search } from "../../../icons/app" import { F0Select } from "../index" @@ -93,7 +94,7 @@ describe("Select", () => { }) const openSelect = async (user: ReturnType) => { - user.click(screen.getByRole("combobox")) + await user.click(screen.getByRole("combobox")) // Wait for animation to finish await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) @@ -101,6 +102,53 @@ describe("Select", () => { fireEvent.animationStart(teaser) } + const createDeferredOptionsSource = () => { + let resolveFetch: (() => void) | undefined + const source = createDataSourceDefinition({ + dataAdapter: { + paginationType: "infinite-scroll", + fetchData: async () => { + await new Promise((resolve) => { + resolveFetch = resolve + }) + return { + type: "infinite-scroll" as const, + cursor: undefined, + perPage: 100, + hasMore: false, + records: [ + { id: "option1", name: "Option 1" }, + { id: "option2", name: "Option 2" }, + ], + total: 2, + } + }, + }, + }) + + return { + source, + resolve: () => { + if (!resolveFetch) { + throw new Error("The deferred options request has not started") + } + resolveFetch() + }, + } + } + + const getSelectContent = () => { + const content = screen + .getByRole("listbox") + .closest("[data-radix-select-content]") + + if (!content) { + throw new Error("Select content shell not found") + } + + return content + } + it("renders with placeholder", async () => { render( { await openSelect(user) - const listbox = screen.getByRole("listbox") - expect(listbox.className).toContain("w-max") - expect(listbox.className).not.toContain("min-w-80") + const content = getSelectContent() + expect(content.className).toContain("w-max") + expect(content.className).not.toContain("min-w-80") }) it("keeps the default 20rem dropdown minimum without fitContentWidth", async () => { @@ -260,7 +308,500 @@ describe("Select", () => { await openSelect(user) - expect(screen.getByRole("listbox").className).toContain("min-w-80") + expect(getSelectContent().className).toContain("min-w-80") + }) + + it("keeps the field presentation and sm default when variant is omitted", () => { + render( + {}} + /> + ) + + const fieldWrapper = screen.getByTestId("input-field-wrapper") + + expect(fieldWrapper).toHaveClass("h-[32px]", "rounded") + expect(fieldWrapper).not.toHaveClass("h-[40px]", "rounded-md") + expect(screen.getByRole("combobox").className).not.toContain("h-7") + }) + + describe("inline variant", () => { + it("exposes the single, non-clearable inline type contract", () => { + type InlineProps = Extract, { variant: "inline" }> + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + false | undefined + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf< + InlineProps["withApplySelection"] + >().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + const roleOptions = [ + { + value: "owner", + label: "Owner", + description: "Can manage access and change roles", + }, + { + value: "editor", + label: "Editor", + description: "Can view and edit", + }, + { + value: "viewer", + label: "Viewer", + description: "Can view", + }, + ] + + it("renders selected and placeholder states and follows controlled updates", async () => { + const { rerender } = render( + {}} + /> + ) + + const trigger = screen.getByRole("combobox", { name: "Access level" }) + expect(within(trigger).getByText("Viewer")).toBeInTheDocument() + expect(screen.queryByText("Access level")).not.toBeInTheDocument() + + rerender( + {}} + /> + ) + + await waitFor(() => { + expect(within(trigger).getByText("Editor")).toBeInTheDocument() + }) + + rerender( + {}} + /> + ) + + await waitFor(() => { + expect(within(trigger).getByText("Select role")).toBeInTheDocument() + }) + }) + + it("falls back to the label when empty and no placeholder is provided", () => { + render( + {}} + /> + ) + + const trigger = screen.getByRole("combobox", { name: "Access level" }) + expect(within(trigger).getByText("Access level")).toBeInTheDocument() + }) + + it("uses fixed md dimensions, label typography, and the default icon color", () => { + render( + {}} + /> + ) + + const trigger = screen.getByRole("combobox", { name: "Access level" }) + const chevron = trigger.querySelector("[aria-hidden='true']") + + expect(trigger.className).toContain("h-8") + expect(trigger.className).toContain("pl-3") + expect(trigger.className).toContain("pr-2") + expect(trigger.className).toContain("text-base") + expect(trigger.className).toContain("font-medium") + expect(trigger.className).not.toContain("text-sm") + expect(chevron).toHaveClass("text-f1-icon") + expect(chevron).not.toHaveClass("text-f1-icon-secondary") + }) + + it("uses defaultItem while a data source is loading", () => { + const source = createDataSourceDefinition({ + dataAdapter: { + paginationType: "infinite-scroll", + fetchData: () => + Promise.resolve({ + type: "infinite-scroll" as const, + cursor: undefined, + perPage: 100, + hasMore: false, + records: [], + total: 0, + }), + }, + }) + + render( + ({ + value: item.id as string, + label: item.name as string, + })} + value="viewer" + defaultItem={{ value: "viewer", label: "Viewer" }} + onChange={() => {}} + /> + ) + + expect( + within( + screen.getByRole("combobox", { name: "Access level" }) + ).getByText("Viewer") + ).toBeInTheDocument() + }) + + it("uses intrinsic borderless trigger styling and a plain chevron", () => { + render( + {}} + /> + ) + + const trigger = screen.getByRole("combobox", { name: "Access level" }) + expect(trigger.className).toContain("w-fit") + expect(trigger.className).toContain("gap-1") + expect(trigger).toHaveClass("rounded") + expect(trigger).not.toHaveClass("rounded-sm") + expect(trigger).not.toHaveClass("rounded-md") + expect(trigger.className).toContain("border-0") + expect(trigger.className).toContain("bg-transparent") + expect(trigger.className).toContain("shadow-none") + + const chevron = trigger.querySelector("svg") + expect(chevron).toBeInTheDocument() + expect(chevron?.parentElement?.className).not.toContain("bg-") + }) + + it("does not open when disabled", async () => { + const user = userEvent.setup() + render( + {}} + /> + ) + + const trigger = screen.getByRole("combobox", { name: "Access level" }) + expect(trigger).toBeDisabled() + expect(trigger.className).toContain("disabled:bg-f1-background-tertiary") + expect(trigger.className).toContain( + "disabled:text-f1-foreground-disabled" + ) + + await user.click(trigger) + + expect(trigger).toHaveAttribute("aria-expanded", "false") + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + }) + + it("selects an option and reports it through onChange", async () => { + const user = userEvent.setup() + const handleChange = vi.fn() + + const ControlledInlineSelect = () => { + const [value, setValue] = useState("viewer") + + return ( + { + handleChange(nextValue, originalItem, option) + setValue(nextValue) + }} + /> + ) + } + + render() + + await openSelect(user) + await user.keyboard("{ArrowUp}{Enter}") + + await waitFor(() => { + expect(handleChange).toHaveBeenCalledWith( + "editor", + undefined, + expect.objectContaining({ value: "editor", label: "Editor" }) + ) + expect(handleChange).toHaveBeenCalledTimes(1) + }) + await waitFor(() => { + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + }) + expect( + within( + screen.getByRole("combobox", { name: "Access level" }) + ).getByText("Editor") + ).toBeInTheDocument() + }) + + it("restores a controlled value rejected by the parent and allows retrying it", async () => { + const user = userEvent.setup() + const handleChange = vi.fn() + render( + + ) + + const trigger = screen.getByRole("combobox", { name: "Access level" }) + + await openSelect(user) + await user.keyboard("{ArrowUp}{Enter}") + + await waitFor(() => { + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenLastCalledWith( + "editor", + undefined, + expect.objectContaining({ value: "editor", label: "Editor" }) + ) + expect(within(trigger).getByText("Viewer")).toBeInTheDocument() + }) + + await waitFor(() => { + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + }) + await openSelect(user) + + expect(screen.getByRole("option", { name: /Viewer/ })).toHaveAttribute( + "data-state", + "checked" + ) + expect(screen.getByRole("option", { name: /Editor/ })).toHaveAttribute( + "data-state", + "unchecked" + ) + + await user.keyboard("{ArrowUp}{Enter}") + + await waitFor(() => { + expect(handleChange).toHaveBeenCalledTimes(2) + expect(handleChange).toHaveBeenLastCalledWith( + "editor", + undefined, + expect.objectContaining({ value: "editor", label: "Editor" }) + ) + expect(within(trigger).getByText("Viewer")).toBeInTheDocument() + }) + }) + + it("defaults to content width and honors an explicit popup-width override", async () => { + const user = userEvent.setup() + const firstRender = render( + {}} + /> + ) + + await openSelect(user) + expect(getSelectContent().className).toContain("w-max") + + firstRender.unmount() + + render( + {}} + /> + ) + + await openSelect(user) + expect(getSelectContent().className).toContain("min-w-80") + }) + + it("reuses the standard popup and option presentation", async () => { + const user = userEvent.setup() + render( + {}} + /> + ) + + await openSelect(user) + + const content = getSelectContent() + const option = screen.getByRole("option", { name: /Viewer/ }) + const description = screen.getByText("Can view") + const indicator = option.querySelector(".text-f1-icon-selected") + + await waitFor(() => expect(option).toHaveFocus()) + + expect(content.className).toContain("rounded-md") + expect(content.className).toContain("shadow-md") + expect(option.className).toContain("px-3") + expect(option.className).toContain("py-2") + expect(option.className).toContain( + "data-[state=checked]:after:bg-f1-background-selected-bold/10" + ) + expect(option.className).toContain("grid-cols-[1fr_20px]") + expect(description.className).not.toContain("text-sm") + expect(indicator).toBeInTheDocument() + expect(option.lastElementChild).toBe(indicator) + expect(indicator?.querySelector("svg")?.className.baseVal).toContain( + "w-5" + ) + }) + + it("runs enabled footer actions and blocks disabled ones", async () => { + const user = userEvent.setup() + const handleRemove = vi.fn() + const handleDisabledAction = vi.fn() + render( + {}} + showSearchBox + actions={[ + { + label: "Remove access", + variant: "critical", + onClick: handleRemove, + }, + { + label: "Unavailable action", + onClick: handleDisabledAction, + disabled: true, + }, + ]} + /> + ) + + await openSelect(user) + const search = screen.getByRole("searchbox") + const action = screen.getByRole("button", { name: "Remove access" }) + const disabledAction = screen.getByRole("button", { + name: "Unavailable action", + }) + + expect(disabledAction).toBeDisabled() + await user.click(disabledAction) + + search.focus() + await user.tab() + expect(document.activeElement).toHaveAttribute("role", "option") + + await user.tab() + expect(action).toHaveFocus() + + await user.tab({ shift: true }) + expect(document.activeElement).toHaveAttribute("role", "option") + + await user.tab({ shift: true }) + expect(search).toHaveFocus() + + await user.tab() + await user.tab() + expect(action).toHaveFocus() + + await user.keyboard("{Enter}") + + await waitFor(() => { + expect(handleRemove).toHaveBeenCalledOnce() + }) + expect(handleDisabledAction).not.toHaveBeenCalled() + }) + + it("forwards refs through both trigger variants", () => { + const fieldRef = createRef() + const field = render( + {}} + /> + ) + + expect(fieldRef.current).not.toBeNull() + field.unmount() + + const inlineRef = createRef() + render( + {}} + /> + ) + + expect(inlineRef.current).toBe( + screen.getByRole("combobox", { name: "Inline access level" }) + ) + }) }) it("should display selected value", async () => { @@ -494,22 +1035,102 @@ describe("Select", () => { it("should not lose the focus when the search input is focused and the list changes", async () => { const user = userEvent.setup({ delay: 100 }) + const onSearchChange = vi.fn() render( {}} + onSearchChange={onSearchChange} showSearchBox /> ) await openSelect(user) - await user.type(screen.getByRole("searchbox"), "Option 1") + const searchInput = screen.getByRole("searchbox") + await user.type(searchInput, "Option 1") + await waitFor(() => + expect(onSearchChange).toHaveBeenLastCalledWith("Option 1") + ) expect(screen.getByText("Option 1")).toBeInTheDocument() await waitFor(() => expect(screen.queryByText("Option 2")).not.toBeInTheDocument() ) + expect(searchInput).toHaveFocus() + }) + + it("keeps search focus when async options load", async () => { + const user = userEvent.setup() + const deferredOptions = createDeferredOptionsSource() + + render( + ({ + value: item.id as string, + label: item.name as string, + })} + value="option2" + defaultItem={{ value: "option2", label: "Option 2" }} + showSearchBox + /> + ) + + await openSelect(user) + const searchInput = screen.getByRole("searchbox") + await waitFor(() => expect(searchInput).toHaveFocus()) + + deferredOptions.resolve() + + await waitFor(() => + expect( + within(screen.getByRole("listbox")).getByRole("option", { + name: "Option 2", + }) + ).toBeInTheDocument() + ) + expect(searchInput).toHaveFocus() + }) + + it("keeps footer focus when async options load", async () => { + const user = userEvent.setup() + const deferredOptions = createDeferredOptionsSource() + + render( + ({ + value: item.id as string, + label: item.name as string, + })} + value="option2" + defaultItem={{ value: "option2", label: "Option 2" }} + showSearchBox + actions={[{ label: "Manage options", onClick: vi.fn() }]} + /> + ) + + await openSelect(user) + const searchInput = screen.getByRole("searchbox") + const footerAction = screen.getByRole("button", { name: "Manage options" }) + await waitFor(() => expect(searchInput).toHaveFocus()) + + await user.tab() + expect(footerAction).toHaveFocus() + + deferredOptions.resolve() + + await waitFor(() => + expect( + within(screen.getByRole("listbox")).getByRole("option", { + name: "Option 2", + }) + ).toBeInTheDocument() + ) + expect(footerAction).toHaveFocus() }) it("shows empty message when no options match search", async () => { diff --git a/packages/react/src/components/F0Select/components/SelectBottomActions.tsx b/packages/react/src/components/F0Select/components/SelectBottomActions.tsx index e38e05f4be..c2b65dd039 100644 --- a/packages/react/src/components/F0Select/components/SelectBottomActions.tsx +++ b/packages/react/src/components/F0Select/components/SelectBottomActions.tsx @@ -40,6 +40,7 @@ export const SelectBottomActions = ({ onClick={action.onClick} icon={action.icon} label={action.label} + disabled={action.disabled} /> ))} {showCancelButton && ( diff --git a/packages/react/src/components/F0Select/types.ts b/packages/react/src/components/F0Select/types.ts index 4f7eb5c31c..43d7d9b3bd 100644 --- a/packages/react/src/components/F0Select/types.ts +++ b/packages/react/src/components/F0Select/types.ts @@ -13,8 +13,8 @@ import type { SortingsDefinition, } from "@/hooks/datasource" -import { WithDataTestIdProps } from "@/lib/data-testid" import { INPUTFIELD_SIZES, InputFieldProps } from "@/components/F0InputField" +import { WithDataTestIdProps } from "@/lib/data-testid" import { Action } from "./components/SelectBottomActions" @@ -26,17 +26,15 @@ export type ResolvedRecordType = R extends RecordType ? R : RecordType */ export type { FiltersState, OnSelectItemsCallback, SelectedItemsState } -/** - * Base props shared across all F0Select variants - */ -type F0SelectBaseProps = { - withApplySelection?: boolean - applySelectionLabel?: string +export const selectVariants = ["field", "inline"] as const +export type F0SelectVariant = (typeof selectVariants)[number] + +/** Props shared by the field and inline select variants. */ +type F0SelectPopupProps = { onChangeSelectedOption?: ( option: F0SelectItemObject> | undefined, checked: boolean ) => void - children?: React.ReactNode open?: boolean showSearchBox?: boolean searchBoxPlaceholder?: string @@ -50,23 +48,11 @@ type F0SelectBaseProps = { */ onFiltersChange?: (filters: FiltersState) => void searchEmptyMessage?: string - className?: string actions?: Action[] /** Callback to create a new item from the current search text. When provided, a "+ Create" button is shown in the empty state of the dropdown. */ onCreate?: (value: string) => Promise | void /** Container element to render the portal content into */ portalContainer?: HTMLElement | null - /** - * When true, renders the select as a static list without the input trigger. - * Only displays the dropdown content with max height, border and scroll. - */ - asList?: boolean - /** - * When true, shows a selection preview panel on the right side of the dropdown - * for multi-select mode. When false and filters are present, filters use compact mode. - * @default false - */ - showPreview?: boolean /** * When true, preserves selections when the dataset changes (search, filters, * or sortings). Useful for picker components where the user searches and @@ -80,105 +66,119 @@ type F0SelectBaseProps = { * the trigger) instead of the default 20rem minimum. Useful for compact * value pickers like month/year selectors. * - * @default false + * @default false for field selects; true for inline selects */ fitContentWidth?: boolean } & WithDataTestIdProps -/** - * Select component for choosing from a list of options. - * - * @template T - The type of the emitted value - * @template R - The type of the record/item data (used with data source) - */ -export type F0SelectProps = F0SelectBaseProps< +type F0SelectSingleSelectionProps = { + clearable?: false + multiple?: false + value?: T + defaultItem?: F0SelectItemObject> + onChange?: ( + value: T, + originalItem?: ResolvedRecordType | undefined, + option?: F0SelectItemObject> + ) => void + /** Callback for selection changes - provides full selection state for advanced use cases (e.g., "Select All" with exclusions) */ + onSelectItems?: never +} + +type F0SelectSelectionProps = + | F0SelectSingleSelectionProps + // Single select clearable + | { + clearable: true + multiple?: false + value?: T + defaultItem?: F0SelectItemObject> + onChange?: ( + value: T, + originalItem?: ResolvedRecordType | undefined, + option?: F0SelectItemObject> + ) => void + onSelectItems?: never + } + // Multiple select + | { + multiple: true + clearable?: boolean + value?: T[] + defaultItem?: F0SelectItemObject>[] + onChange?: ( + value: T[], + originalItems: ResolvedRecordType[], + options: F0SelectItemObject>[] + ) => void + /** + * Callback for selection changes - provides full selection state including: + * - `status.allSelected`: true if "Select All" was used, "indeterminate" if some items deselected after Select All + * - `status.items`: Map of all items with their checked state + * - `filters`: Current applied filters + * - `selectedCount`: Total number of selected items + * + * Use this for "chunked" selection mode where you need to track: + * - When allSelected is true/indeterminate: excluded items are those with checked=false + * - When allSelected is false: included items are those with checked=true + */ + onSelectItems?: OnSelectItemsCallback< + ResolvedRecordType, + FiltersDefinition + > + /** + * Disables the "Select All" functionality, forcing manual selection of items one by one. + * When enabled, the allSelected state will always be false and users must select items individually. + */ + disableSelectAll?: boolean + } + +type F0SelectDataProps = + | { + source: DataSourceDefinition< + ResolvedRecordType, + FiltersDefinition, + SortingsDefinition, + GroupingDefinition> + > + mapOptions: ( + item: ResolvedRecordType + ) => F0SelectItemProps> + options?: never + } + | { + source?: never + mapOptions?: never + searchFn?: ( + option: F0SelectItemProps, + search?: string + ) => boolean | undefined + options: F0SelectItemProps[] + } + +type F0SelectFieldProps = F0SelectPopupProps< T, R -> & // Single select not clearable - ( - | { - clearable?: false - multiple?: false - value?: T - defaultItem?: F0SelectItemObject> - onChange?: ( - value: T, - originalItem?: ResolvedRecordType | undefined, - option?: F0SelectItemObject> - ) => void - /** Callback for selection changes - provides full selection state for advanced use cases (e.g., "Select All" with exclusions) */ - onSelectItems?: never - } - // Single select clearable - | { - clearable: true - multiple?: false - value?: T - defaultItem?: F0SelectItemObject> - onChange?: ( - value: T, - originalItem?: ResolvedRecordType | undefined, - option?: F0SelectItemObject> - ) => void - onSelectItems?: never - } - // Multiple select - | { - multiple: true - clearable?: boolean - value?: T[] - defaultItem?: F0SelectItemObject>[] - onChange?: ( - value: T[], - originalItems: ResolvedRecordType[], - options: F0SelectItemObject>[] - ) => void - /** - * Callback for selection changes - provides full selection state including: - * - `status.allSelected`: true if "Select All" was used, "indeterminate" if some items deselected after Select All - * - `status.items`: Map of all items with their checked state - * - `filters`: Current applied filters - * - `selectedCount`: Total number of selected items - * - * Use this for "chunked" selection mode where you need to track: - * - When allSelected is true/indeterminate: excluded items are those with checked=false - * - When allSelected is false: included items are those with checked=true - */ - onSelectItems?: OnSelectItemsCallback< - ResolvedRecordType, - FiltersDefinition - > - /** - * Disables the "Select All" functionality, forcing manual selection of items one by one. - * When enabled, the allSelected state will always be false and users must select items individually. - */ - disableSelectAll?: boolean - } - ) & - ( - | { - source: DataSourceDefinition< - ResolvedRecordType, - FiltersDefinition, - SortingsDefinition, - GroupingDefinition> - > - mapOptions: ( - item: ResolvedRecordType - ) => F0SelectItemProps> - options?: never - } - | { - source?: never - mapOptions?: never - searchFn?: ( - option: F0SelectItemProps, - search?: string - ) => boolean | undefined - options: F0SelectItemProps[] - } - ) & - Pick< +> & + F0SelectSelectionProps & { + /** Standard form-field presentation. This remains the default. */ + variant?: "field" + withApplySelection?: boolean + applySelectionLabel?: string + children?: React.ReactNode + className?: string + /** + * When true, renders the select as a static list without the input trigger. + * Only displays the dropdown content with max height, border and scroll. + */ + asList?: boolean + /** + * When true, shows a selection preview panel on the right side of the dropdown + * for multi-select mode. When false and filters are present, filters use compact mode. + * @default false + */ + showPreview?: boolean + } & Pick< InputFieldProps, | "required" | "loading" @@ -195,6 +195,48 @@ export type F0SelectProps = F0SelectBaseProps< | "hint" > +type F0SelectInlineProps = F0SelectPopupProps< + T, + R +> & + F0SelectSingleSelectionProps & + Pick, "label" | "placeholder" | "disabled"> & { + /** + * Compact borderless presentation for single-value controls embedded in rows. + * The required label is used as the accessible name and is not shown visually. + */ + variant: "inline" + size?: never + disableSelectAll?: never + withApplySelection?: never + applySelectionLabel?: never + children?: never + className?: never + asList?: never + showPreview?: never + required?: never + loading?: never + hideLabel?: never + labelIcon?: never + icon?: never + name?: never + error?: never + status?: never + hint?: never + } + +/** + * Select component for choosing from a list of options. + * + * @template T - The type of the emitted value + * @template R - The type of the record/item data (used with data source) + */ +export type F0SelectProps = ( + | F0SelectFieldProps + | F0SelectInlineProps +) & + F0SelectDataProps + export type F0SelectTagProp = | string | { type: "dot"; text: string; color: NewColor } diff --git a/packages/react/src/experimental/Navigation/Header/Breadcrumbs/internal/BreadcrumbSelect/index.tsx b/packages/react/src/experimental/Navigation/Header/Breadcrumbs/internal/BreadcrumbSelect/index.tsx index 443823e7cc..aaa71ec075 100644 --- a/packages/react/src/experimental/Navigation/Header/Breadcrumbs/internal/BreadcrumbSelect/index.tsx +++ b/packages/react/src/experimental/Navigation/Header/Breadcrumbs/internal/BreadcrumbSelect/index.tsx @@ -13,7 +13,7 @@ import { ChevronDown } from "@/icons/app" export type BreadcrumbSelectProps< T extends string, R = unknown, -> = F0SelectProps & { multiple?: false } +> = F0SelectProps & { multiple?: false; variant?: "field" } export function BreadcrumbSelect({ ...props diff --git a/packages/react/src/ui/Select/components/SelectContent.test.tsx b/packages/react/src/ui/Select/components/SelectContent.test.tsx new file mode 100644 index 0000000000..09d5c870cf --- /dev/null +++ b/packages/react/src/ui/Select/components/SelectContent.test.tsx @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from "vitest" +import { useEffect, useState } from "react" +import { flushSync } from "react-dom" + +import { + act, + screen, + userEvent, + waitFor, + zeroRender as render, +} from "@/testing/test-utils" + +import { Select } from "./Select" +import { SelectContent } from "./SelectContent" +import { SelectItem } from "./SelectItem" +import { SelectTrigger } from "./SelectTrigger" + +function DeferredSelectItem({ + value, + label, + delay = 20, +}: { + value: string + label: string + delay?: number +}) { + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + const timeout = setTimeout(() => setIsVisible(true), delay) + return () => clearTimeout(timeout) + }, [delay]) + + return isVisible ? {label} : null +} + +describe("SelectContent", () => { + it("focuses the selected option when it mounts after the popup", async () => { + render( + + ) + + const selectedOption = await screen.findByRole("option", { name: "First" }) + + await waitFor(() => expect(selectedOption).toHaveFocus()) + }) + + it("moves focus from its fallback to a selected option that mounts after the grace period", async () => { + render( + + ) + + const fallbackOption = screen.getByRole("option", { name: "Second" }) + await waitFor(() => expect(fallbackOption).toHaveFocus()) + + const selectedOption = await screen.findByRole("option", { name: "First" }) + await waitFor(() => expect(selectedOption).toHaveFocus()) + }) + + it("focuses the first option after it mounts for a placeholder value", async () => { + render( + + ) + + const firstOption = await screen.findByRole("option", { name: "First" }) + + await waitFor(() => expect(firstOption).toHaveFocus()) + }) + + it("focuses the first option when the current value does not exist", async () => { + render( + + ) + + const firstOption = await screen.findByRole("option", { name: "First" }) + + await waitFor(() => expect(firstOption).toHaveFocus()) + }) + + it("cleans up the fallback timer when focusing content closes the popup", async () => { + vi.useFakeTimers() + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout") + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout") + + function CloseOnContentFocus() { + const [open, setOpen] = useState(true) + + return ( + <> + + + + ) + } + + try { + const { unmount } = render() + const firstOption = screen.getByRole("option", { name: "First" }) + const firstOptionFocusSpy = vi.spyOn(firstOption, "focus") + + await act(async () => vi.advanceTimersByTimeAsync(20)) + + const fallbackTimerCallIndex = setTimeoutSpy.mock.calls.findIndex( + ([, delay]) => delay === 50 + ) + const fallbackTimer = + setTimeoutSpy.mock.results[fallbackTimerCallIndex]?.value + + expect(fallbackTimerCallIndex).toBeGreaterThanOrEqual(0) + expect(clearTimeoutSpy).toHaveBeenCalledWith(fallbackTimer) + + const outsideAction = screen.getByRole("button", { + name: "Outside action", + }) + outsideAction.focus() + await act(async () => vi.advanceTimersByTimeAsync(100)) + + expect(outsideAction).toHaveFocus() + expect(firstOptionFocusSpy).not.toHaveBeenCalled() + + unmount() + } finally { + setTimeoutSpy.mockRestore() + clearTimeoutSpy.mockRestore() + vi.useRealTimers() + } + }) + + it("keeps footer actions outside the listbox and traverses them with Tab", async () => { + const user = userEvent.setup() + + render( + + ) + + const listbox = await screen.findByRole("listbox", { + name: "Available options", + }) + const option = screen.getByRole("option", { name: "First" }) + const createAction = screen.getByRole("button", { name: "Create option" }) + const manageAction = screen.getByRole("button", { name: "Manage options" }) + + expect(listbox.contains(createAction)).toBe(false) + expect(listbox.contains(manageAction)).toBe(false) + expect(listbox.closest("[data-radix-select-content]")).not.toHaveAttribute( + "aria-label" + ) + + option.focus() + expect(option).toHaveFocus() + + await user.tab() + + expect(createAction).toHaveFocus() + + await user.tab() + expect(manageAction).toHaveFocus() + + await user.tab({ shift: true }) + expect(createAction).toHaveFocus() + + await user.tab({ shift: true }) + expect(option).toHaveFocus() + }) + + it("keeps an empty-state action outside the listbox and reaches it from the header", async () => { + const user = userEvent.setup() + + render( + + ) + + const listbox = await screen.findByRole("listbox") + const search = screen.getByRole("searchbox", { name: "Search options" }) + const action = screen.getByRole("button", { name: "Create option" }) + + expect(listbox.contains(action)).toBe(false) + expect(screen.getByRole("option", { name: "No results" })).toHaveAttribute( + "aria-disabled", + "true" + ) + + search.focus() + await user.tab() + + expect(action).toHaveFocus() + + await user.tab({ shift: true }) + expect(search).toHaveFocus() + }) + + it("keeps text editing keys inside the searchbox", async () => { + const user = userEvent.setup() + + render( + + ) + + await screen.findByRole("listbox") + const search = screen.getByRole("searchbox", { name: "Search options" }) + + search.focus() + search.setSelectionRange(search.value.length, search.value.length) + await user.keyboard("s") + + expect(search).toHaveFocus() + expect(search).toHaveValue("querys") + + search.setSelectionRange(2, 2) + await user.keyboard("{Home}") + expect(search.selectionStart).toBe(0) + + await user.keyboard("{End}") + expect(search.selectionStart).toBe(search.value.length) + expect(search).toHaveFocus() + }) + + it("keeps typeahead navigation on options", async () => { + const user = userEvent.setup() + + render( + + ) + + await screen.findByRole("listbox") + const firstOption = screen.getByRole("option", { name: "First" }) + const secondOption = screen.getByRole("option", { name: "Second" }) + + firstOption.focus() + await user.keyboard("s") + + expect(secondOption).toHaveFocus() + }) +}) diff --git a/packages/react/src/ui/Select/components/SelectContent.tsx b/packages/react/src/ui/Select/components/SelectContent.tsx index a2aa125179..fdc14aebe9 100644 --- a/packages/react/src/ui/Select/components/SelectContent.tsx +++ b/packages/react/src/ui/Select/components/SelectContent.tsx @@ -23,6 +23,15 @@ import * as SelectPrimitive from "./radix-ui" const VIEWBOX_VERTICAL_PADDING = 8 +const TABBABLE_ELEMENT_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(",") + /** * Select Content component */ @@ -100,6 +109,10 @@ const SelectContent = forwardRef< showLoadingIndicator, asChild, portalContainer, + bottom, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, ...props }, ref @@ -123,6 +136,7 @@ const SelectContent = forwardRef< // ----------- Virtual list ----------- // The scrollable element for your list const parentRef = useRef(null) + const lastTabbedOptionRef = useRef(null) const isVirtual = Array.isArray(items) const isEmpty = useMemo(() => { @@ -224,14 +238,79 @@ const SelectContent = forwardRef< const virtualItems = virtualizer.getVirtualItems() + const handleContentKeyDown: NonNullable< + ComponentPropsWithoutRef["onKeyDown"] + > = (event) => { + props.onKeyDown?.(event) + + if (event.defaultPrevented || event.key !== "Tab") { + return + } + + const eventTarget = event.target as HTMLElement + const content = event.currentTarget + const focusedOption = eventTarget.closest('[role="option"]') + + if ( + focusedOption && + focusedOption.getAttribute("aria-disabled") !== "true" + ) { + lastTabbedOptionRef.current = focusedOption + } + + const activeOption = + focusedOption ?? + (lastTabbedOptionRef.current?.isConnected + ? lastTabbedOptionRef.current + : content.querySelector( + '[role="option"][data-highlighted]:not([aria-disabled="true"]), [role="option"][data-state="checked"]:not([aria-disabled="true"]), [role="option"]:not([aria-disabled="true"])' + )) + const controls = Array.from( + content.querySelectorAll(TABBABLE_ELEMENT_SELECTOR) + ).filter( + (element) => + (element.tabIndex >= 0 || + element.getAttribute("role") === "searchbox") && + !element.matches("[data-radix-scroll-area-viewport]") && + !element.closest( + '[hidden], [aria-hidden="true"], [inert], [role="listbox"]' + ) + ) + const currentControl = + !focusedOption && + eventTarget !== content && + !eventTarget.closest('[role="listbox"]') + ? eventTarget + : undefined + const focusTargets = Array.from( + new Set([ + ...controls, + ...(activeOption ? [activeOption] : []), + ...(currentControl ? [currentControl] : []), + ]) + ).sort((first, second) => + first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1 + ) + const currentFocusTarget = focusedOption ?? eventTarget + const currentIndex = focusTargets.indexOf(currentFocusTarget) + const nextFocusTarget = + currentIndex >= 0 + ? focusTargets[currentIndex + (event.shiftKey ? -1 : 1)] + : undefined + + if (nextFocusTarget) { + event.preventDefault() + nextFocusTarget.focus() + } + } + const viewportContent = isEmpty ? (
-

{emptyMessage || "-"}

- {emptyAction && ( -
- {emptyAction} -
- )} +
+

{emptyMessage || "-"}

+
) : isVirtual ? (
(
{isLoadingMore && index === virtualItems.length - 1 ? (
@@ -283,7 +363,7 @@ const SelectContent = forwardRef<
) : ( - <>{children} +
{children}
) const loadingNewContent = isLoading && !isLoadingMore @@ -325,6 +405,7 @@ const SelectContent = forwardRef< collisionPadding={16} avoidCollisions {...props} + onKeyDown={handleContentKeyDown} // Prevent the default focus restoration when the select closes. // This avoids infinite focus loops when the select is inside a modal // or other focus-trapping container. @@ -399,25 +480,48 @@ const SelectContent = forwardRef< scrollMargin={scrollMargin} > {asList ? ( -
{viewportContent}
+ +
{viewportContent}
+
) : ( - - {viewportContent} - + + {viewportContent} + + )}
{props.right}
- {props.bottom &&
{props.bottom}
} + {(isEmpty && emptyAction) || bottom ? ( +
+ {isEmpty && emptyAction && ( +
+ {emptyAction} +
+ )} + {bottom} +
+ ) : null}
) diff --git a/packages/react/src/ui/Select/components/radix-ui/index.ts b/packages/react/src/ui/Select/components/radix-ui/index.ts index 1690e35022..614a7ed132 100644 --- a/packages/react/src/ui/Select/components/radix-ui/index.ts +++ b/packages/react/src/ui/Select/components/radix-ui/index.ts @@ -8,6 +8,7 @@ export { ItemIndicator, ItemText, Label, + Listbox, Portal, // Root, @@ -23,6 +24,7 @@ export { SelectItemIndicator, SelectItemText, SelectLabel, + SelectListbox, SelectPortal, SelectScrollDownButton, SelectScrollUpButton, @@ -45,6 +47,7 @@ export type { SelectItemProps, SelectItemTextProps, SelectLabelProps, + SelectListboxProps, SelectPortalProps, SelectProps, SelectScrollDownButtonProps, diff --git a/packages/react/src/ui/Select/components/radix-ui/select.tsx b/packages/react/src/ui/Select/components/radix-ui/select.tsx index 33e65825fc..e493397b8f 100644 --- a/packages/react/src/ui/Select/components/radix-ui/select.tsx +++ b/packages/react/src/ui/Select/components/radix-ui/select.tsx @@ -29,6 +29,7 @@ type Direction = "ltr" | "rtl" const OPEN_KEYS = [" ", "Enter", "ArrowUp", "ArrowDown"] const SELECTION_KEYS = [" ", "Enter"] +const SELECTED_ITEM_FALLBACK_DELAY = 50 /* ------------------------------------------------------------------------------------------------- * Select @@ -716,20 +717,129 @@ const SelectContentImpl = React.forwardRef< [getItems, viewport] ) - const focusSelectedItem = React.useCallback(() => { - if (!context.multiple) { - focusFirst([selectedItem, content]) - return - } - }, [focusFirst, selectedItem, content, context.multiple]) + const focusSelectedItem = React.useCallback( + (allowFocusFromFallback = false) => { + const activeElement = document.activeElement + const focusIsInsideContent = + activeElement instanceof HTMLElement && + activeElement !== content && + content?.contains(activeElement) + + if (focusIsInsideContent && !allowFocusFromFallback) { + return + } + + if (!context.multiple) { + focusFirst([selectedItem, content]) + return + } + }, + [focusFirst, selectedItem, content, context.multiple] + ) // 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`. + const hasFocusedOnOpenRef = React.useRef(false) + const focusFallbackRef = React.useRef(null) + const focusSelectedItemRef = React.useRef(focusSelectedItem) + focusSelectedItemRef.current = focusSelectedItem React.useEffect(() => { - if (isPositioned) { - focusSelectedItem() + if (!context.open) { + hasFocusedOnOpenRef.current = false + focusFallbackRef.current = null + return } - }, [isPositioned, focusSelectedItem]) + + if (isPositioned && !hasFocusedOnOpenRef.current) { + let cancelled = false + let fallbackTimeout: ReturnType | undefined + const selectedValues = ( + Array.isArray(context.value) ? context.value : [context.value] + ).filter((value): value is string => value !== undefined) + const isPlaceholderValue = + context.value === undefined || context.value === "" + const selectedItemMatchesCurrentValue = + context.multiple || + (selectedItem !== null && + (isPlaceholderValue || + getItems().some( + (item) => + item.ref.current === selectedItem && + selectedValues.includes(item.value) + ))) + + const timeout = setTimeout(() => { + if (cancelled) return + + const activeElement = document.activeElement + const focusIsInsideContent = + activeElement instanceof HTMLElement && + activeElement !== content && + content?.contains(activeElement) + const fallback = focusFallbackRef.current + + if (focusIsInsideContent && activeElement !== fallback) { + hasFocusedOnOpenRef.current = true + return + } + + const focusCanMove = fallback + ? activeElement === fallback + : activeElement === content || + activeElement === context.trigger || + activeElement === document.body + + if (!selectedItemMatchesCurrentValue) { + if (!focusCanMove) { + hasFocusedOnOpenRef.current = true + return + } + + focusFallbackRef.current = content + + if (selectedItem) { + fallbackTimeout = setTimeout(() => { + if (cancelled || hasFocusedOnOpenRef.current) return + + if (document.activeElement !== focusFallbackRef.current) { + hasFocusedOnOpenRef.current = true + return + } + + selectedItem.focus() + if (cancelled) return + + if (document.activeElement === selectedItem) { + focusFallbackRef.current = selectedItem + } + }, SELECTED_ITEM_FALLBACK_DELAY) + } + + content?.focus() + return + } + + hasFocusedOnOpenRef.current = true + if (focusCanMove) { + focusSelectedItemRef.current(activeElement === fallback) + } + }, 0) + return () => { + cancelled = true + clearTimeout(timeout) + if (fallbackTimeout !== undefined) clearTimeout(fallbackTimeout) + } + } + }, [ + context.multiple, + context.open, + context.trigger, + context.value, + content, + getItems, + isPositioned, + selectedItem, + ]) // prevent selecting items on `pointerup` in some cases after opening from `pointerdown` // and close on `pointerup` outside. @@ -902,8 +1012,7 @@ const SelectContentImpl = React.forwardRef< onDismiss={() => context.onOpenChange(false)} > event.preventDefault()} @@ -924,15 +1033,19 @@ const SelectContentImpl = React.forwardRef< (event) => { const isModifierKey = event.ctrlKey || event.altKey || event.metaKey + const isSearchbox = + event.target instanceof HTMLElement && + event.target.getAttribute("role") === "searchbox" // select should not be navigated using tab key so we prevent it if (event.key === "Tab") event.preventDefault() - if (!isModifierKey && event.key.length === 1) + if (!isModifierKey && !isSearchbox && event.key.length === 1) handleTypeaheadSearch(event.key) if ( - ["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key) + ["ArrowUp", "ArrowDown"].includes(event.key) || + (!isSearchbox && ["Home", "End"].includes(event.key)) ) { const items = getItems().filter((item) => !item.disabled) let candidateNodes = items.map((item) => item.ref.current!) @@ -967,6 +1080,36 @@ const SelectContentImpl = React.forwardRef< SelectContentImpl.displayName = CONTENT_IMPL_NAME +/* ------------------------------------------------------------------------------------------------- + * SelectListbox + * -----------------------------------------------------------------------------------------------*/ + +const LISTBOX_NAME = "SelectListbox" + +type SelectListboxElement = React.ElementRef +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface SelectListboxProps extends PrimitiveDivProps {} + +const SelectListbox = React.forwardRef< + SelectListboxElement, + SelectListboxProps +>((props: ScopedProps, forwardedRef) => { + const { __scopeSelect, ...listboxProps } = props + const context = useSelectContext(LISTBOX_NAME, __scopeSelect) + + return ( + + ) +}) + +SelectListbox.displayName = LISTBOX_NAME + /* ------------------------------------------------------------------------------------------------- * SelectItemAlignedPosition * -----------------------------------------------------------------------------------------------*/ @@ -2106,6 +2249,7 @@ const Value = SelectValue const Icon = SelectIcon const Portal = SelectPortal const Content = SelectContent +const Listbox = SelectListbox const Viewport = SelectViewport const Group = SelectGroup const Label = SelectLabel @@ -2127,6 +2271,7 @@ export { ItemIndicator, ItemText, Label, + Listbox, Portal, // Root, @@ -2142,6 +2287,7 @@ export { SelectItemIndicator, SelectItemText, SelectLabel, + SelectListbox, SelectPortal, SelectScrollDownButton, SelectScrollUpButton, @@ -2163,6 +2309,7 @@ export type { SelectItemProps, SelectItemTextProps, SelectLabelProps, + SelectListboxProps, SelectPortalProps, SelectProps, SelectScrollDownButtonProps,