From 18cdd17c71a49bbae88ade3e2b925dd28f17acb3 Mon Sep 17 00:00:00 2001 From: Tobias Barsnes Date: Mon, 13 May 2024 10:53:11 +0200 Subject: [PATCH] fix(Combobox): Improve performance (#1771) Co-authored-by: Michael Marszalek --- packages/css/CHANGELOG.md | 10 + packages/css/package.json | 2 +- packages/css/react-css-modules.css | 2 +- packages/react-old/CHANGELOG.md | 8 + packages/react-old/package.json | 2 +- packages/react/CHANGELOG.md | 12 + packages/react/package.json | 4 +- .../form/Combobox/Combobox.module.css | 2 +- .../form/Combobox/Combobox.stories.tsx | 76 ++++ .../form/Combobox/Combobox.test.tsx | 31 +- .../src/components/form/Combobox/Combobox.tsx | 413 +++++------------- .../form/Combobox/ComboboxContext.tsx | 53 +++ .../form/Combobox/ComboboxIdContext.tsx | 64 +++ .../form/Combobox/Custom/Custom.tsx | 28 +- .../components/form/Combobox/Empty/Empty.tsx | 6 +- .../form/Combobox/Option/Option.tsx | 170 +++---- .../Combobox/Option/useComboboxOption.tsx | 70 +++ .../form/Combobox/internal/ComboboxChips.tsx | 30 +- .../Combobox/internal/ComboboxClearButton.tsx | 20 +- .../form/Combobox/internal/ComboboxInput.tsx | 144 +++--- .../form/Combobox/internal/ComboboxNative.tsx | 14 +- .../form/Combobox/useCombobox.test.tsx | 28 +- .../components/form/Combobox/useCombobox.tsx | 203 +++++---- .../form/Combobox/useComboboxKeyboard.tsx | 110 +++++ .../form/Combobox/useFloatingCombobox.tsx | 92 ++++ yarn.lock | 61 ++- 26 files changed, 990 insertions(+), 665 deletions(-) create mode 100644 packages/react/src/components/form/Combobox/ComboboxContext.tsx create mode 100644 packages/react/src/components/form/Combobox/ComboboxIdContext.tsx create mode 100644 packages/react/src/components/form/Combobox/Option/useComboboxOption.tsx create mode 100644 packages/react/src/components/form/Combobox/useComboboxKeyboard.tsx create mode 100644 packages/react/src/components/form/Combobox/useFloatingCombobox.tsx diff --git a/packages/css/CHANGELOG.md b/packages/css/CHANGELOG.md index 28adf7d996..b49c9f9f6d 100644 --- a/packages/css/CHANGELOG.md +++ b/packages/css/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.6.1-alpha.1](https://github.com/digdir/designsystemet/compare/@digdir/designsystemet-css@0.6.1-alpha.0...@digdir/designsystemet-css@0.6.1-alpha.1) (2024-04-26) + +### Bug Fixes + +- **Modal:** fix close button position ([#1877](https://github.com/digdir/designsystemet/issues/1877)) ([c866710](https://github.com/digdir/designsystemet/commit/c866710cc00760a8f1a4f1676e2c8a5eda235a72)) + +## [0.6.1-alpha.0](https://github.com/digdir/designsystemet/compare/@digdir/designsystemet-css@0.6.0...@digdir/designsystemet-css@0.6.1-alpha.0) (2024-04-24) + +**Note:** Version bump only for package @digdir/designsystemet-css + # [0.6.0](https://github.com/digdir/designsystemet/compare/@digdir/designsystemet-css@0.5.0...@digdir/designsystemet-css@0.6.0) (2024-04-23) ### Bug Fixes diff --git a/packages/css/package.json b/packages/css/package.json index 996ee0e496..24b2ca2d4e 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@digdir/designsystemet-css", - "version": "0.6.0", + "version": "0.6.1-alpha.1", "description": "CSS for Designsystemet", "author": "Designsystemet team", "repository": "https://github.com/digdir/designsystemet", diff --git a/packages/css/react-css-modules.css b/packages/css/react-css-modules.css index 096bead828..24d1f6c708 100644 --- a/packages/css/react-css-modules.css +++ b/packages/css/react-css-modules.css @@ -345,7 +345,7 @@ /** * Apply a focus outline on an element when it is focused with keyboard */ - .fds-combobox-inFocus-249a725c { + .fds-combobox-inputWrapper-249a725c:has(input:focus) { --fds-focus-border-width: 3px; outline: var(--fds-focus-border-width) solid var(--fds-semantic-border-focus-outline); diff --git a/packages/react-old/CHANGELOG.md b/packages/react-old/CHANGELOG.md index 02e3277c8e..c9231cebd5 100644 --- a/packages/react-old/CHANGELOG.md +++ b/packages/react-old/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.53.10-alpha.1](https://github.com/digdir/designsystemet/compare/@digdir/design-system-react@0.53.10-alpha.0...@digdir/design-system-react@0.53.10-alpha.1) (2024-04-26) + +**Note:** Version bump only for package @digdir/design-system-react + +## [0.53.10-alpha.0](https://github.com/digdir/designsystemet/compare/@digdir/design-system-react@0.53.9...@digdir/design-system-react@0.53.10-alpha.0) (2024-04-24) + +**Note:** Version bump only for package @digdir/design-system-react + ## [0.53.9](https://github.com/digdir/designsystemet/compare/@digdir/design-system-react@0.53.8...@digdir/design-system-react@0.53.9) (2024-04-23) **Note:** Version bump only for package @digdir/design-system-react diff --git a/packages/react-old/package.json b/packages/react-old/package.json index 96967dcdb2..2115840e3b 100644 --- a/packages/react-old/package.json +++ b/packages/react-old/package.json @@ -1,6 +1,6 @@ { "name": "@digdir/design-system-react", - "version": "0.53.9", + "version": "0.53.10-alpha.1", "description": "Legacy React components for Designsystemet", "author": "Designsystemet team", "repository": "https://github.com/digdir/designsystemet", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index bac7f3f500..f6ea06d82a 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.59.1-alpha.1](https://github.com/digdir/designsystemet/compare/@digdir/designsystemet-react@0.59.1-alpha.0...@digdir/designsystemet-react@0.59.1-alpha.1) (2024-04-26) + +### Bug Fixes + +- **Modal:** fix close button position ([#1877](https://github.com/digdir/designsystemet/issues/1877)) ([c866710](https://github.com/digdir/designsystemet/commit/c866710cc00760a8f1a4f1676e2c8a5eda235a72)) + +## [0.59.1-alpha.0](https://github.com/digdir/designsystemet/compare/@digdir/designsystemet-react@0.59.0...@digdir/designsystemet-react@0.59.1-alpha.0) (2024-04-24) + +### Bug Fixes + +- **Combobox:** Re-renders ([24fa39f](https://github.com/digdir/designsystemet/commit/24fa39f5124c9fc0b01590ad3f1e7960c54e5f35)) + # [0.59.0](https://github.com/digdir/designsystemet/compare/@digdir/designsystemet-react@0.58.0...@digdir/designsystemet-react@0.59.0) (2024-04-23) ### Bug Fixes diff --git a/packages/react/package.json b/packages/react/package.json index b9fcbce589..0c459509c1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@digdir/designsystemet-react", - "version": "0.59.0", + "version": "0.59.1-alpha.1", "description": "React components for Designsystemet", "author": "Designsystemet team", "repository": "https://github.com/digdir/designsystemet", @@ -28,7 +28,7 @@ "access": "public" }, "dependencies": { - "@floating-ui/react": "0.26.4", + "@floating-ui/react": "0.26.12", "@navikt/aksel-icons": "^5.12.2", "@radix-ui/react-slot": "^1.0.2", "@tanstack/react-virtual": "^3.2.0" diff --git a/packages/react/src/components/form/Combobox/Combobox.module.css b/packages/react/src/components/form/Combobox/Combobox.module.css index 1a914fb5cc..4ab66af204 100644 --- a/packages/react/src/components/form/Combobox/Combobox.module.css +++ b/packages/react/src/components/form/Combobox/Combobox.module.css @@ -188,7 +188,7 @@ /** * Apply a focus outline on an element when it is focused with keyboard */ - .inFocus { + .inputWrapper:has(input:focus) { --fds-focus-border-width: 3px; outline: var(--fds-focus-border-width) solid var(--fds-semantic-border-focus-outline); diff --git a/packages/react/src/components/form/Combobox/Combobox.stories.tsx b/packages/react/src/components/form/Combobox/Combobox.stories.tsx index 410ab2aaa6..3663f00924 100644 --- a/packages/react/src/components/form/Combobox/Combobox.stories.tsx +++ b/packages/react/src/components/form/Combobox/Combobox.stories.tsx @@ -597,3 +597,79 @@ CustomNewValue.args = { size: 'medium', label: 'Hvor går reisen?', }; + +const items = Array.from({ length: 2000 }, (_, index) => ({ + name: `Option ${index}`, + value: `option-${index}`, +})); + +export const ThousandsOfOptions: StoryFn = (args) => { + return ( + + Fant ingen treff + {items.map((item, index) => ( + + {item.name} + + ))} + + ); +}; + +ThousandsOfOptions.args = { + virtual: true, +}; + +export const RemoveAllOptions: StoryFn = (args) => { + const [selectedValues, setSelectedValues] = React.useState([ + 'test1', + 'test2', + ]); + const [values, setValues] = React.useState(['test1', 'test2']); + + const handleComboboxChange = (values: string[]) => { + setSelectedValues(values); + }; + + const changeAllValues = (deleteValues: boolean) => + setValues(deleteValues ? [] : ['test1', 'test2']); + + const changeSomeValues = (removeTest2: boolean) => + setValues(removeTest2 ? ['test1'] : ['test1', 'test2']); + + const currentSelectedValues = selectedValues.filter((id) => + values.includes(id), + ); + + return ( + <> + + {values.map((attachment) => { + return ( + + ); + })} + + changeAllValues(event.target.checked)}> + Remove Values (Selected values remain unchanged as the combobox does not + update when options are empty.) + + changeSomeValues(event.target.checked)}> + Remove test2 (this works) + + + ); +}; diff --git a/packages/react/src/components/form/Combobox/Combobox.test.tsx b/packages/react/src/components/form/Combobox/Combobox.test.tsx index f7a0eb3632..282af156f9 100644 --- a/packages/react/src/components/form/Combobox/Combobox.test.tsx +++ b/packages/react/src/components/form/Combobox/Combobox.test.tsx @@ -99,6 +99,22 @@ describe('Combobox', () => { expect(screen.queryByText('Leikanger')).not.toBeInTheDocument(); }); + it('should select when we click Enter', async () => { + const onValueChange = vi.fn(); + const { user } = await render({ onValueChange }); + const combobox = screen.getByRole('combobox'); + expect(screen.queryByText('Leikanger')).not.toBeInTheDocument(); + + await userEvent.click(combobox); + expect(screen.getByText('Leikanger')).toBeInTheDocument(); + + await user.type(combobox, '{Enter}'); + + await wait(500); + + expect(onValueChange).toHaveBeenCalledWith(['leikanger']); + }); + it('should set call `onValueChange` on the Combobox when we click and option', async () => { const onValueChange = vi.fn(); await render({ onValueChange }); @@ -165,8 +181,12 @@ describe('Combobox', () => { await wait(100); await userEvent.click(screen.getByText('Leikanger')); await wait(500); + expect(onValueChange).toHaveBeenCalledWith(['leikanger']); + await wait(500); await userEvent.click(screen.getByText('Oslo')); await wait(500); + expect(onValueChange).toHaveBeenCalledWith(['leikanger', 'oslo']); + await wait(500); await user.click(document.body); await wait(500); expect(screen.getByText('Leikanger')).toBeInTheDocument(); @@ -181,12 +201,13 @@ describe('Combobox', () => { throw new Error('Could not find clear button'); } await userEvent.click(clearButton); + await userEvent.click(document.body); - setTimeout(() => { - expect(screen.queryByText('Leikanger')).not.toBeInTheDocument(); - expect(screen.queryByText('Oslo')).not.toBeInTheDocument(); - expect(onValueChange).toHaveBeenCalledWith([]); - }, 1000); + await wait(500); + + expect(screen.queryByText('Leikanger')).not.toBeInTheDocument(); + expect(screen.queryByText('Oslo')).not.toBeInTheDocument(); + expect(onValueChange).toHaveBeenCalledWith([]); }); it('should show "Fant ingen treff", when input does not match any values', async () => { diff --git a/packages/react/src/components/form/Combobox/Combobox.tsx b/packages/react/src/components/form/Combobox/Combobox.tsx index b64062ad6c..5397af840e 100644 --- a/packages/react/src/components/form/Combobox/Combobox.tsx +++ b/packages/react/src/components/form/Combobox/Combobox.tsx @@ -1,32 +1,8 @@ -import { - useState, - useRef, - createContext, - useEffect, - useId, - forwardRef, -} from 'react'; +import { useState, useRef, useEffect, useId, forwardRef } from 'react'; import type * as React from 'react'; -import { - FloatingFocusManager, - autoUpdate, - flip, - offset, - size as floatingSize, - useDismiss, - useFloating, - useInteractions, - useListNavigation, - useRole, - FloatingPortal, -} from '@floating-ui/react'; +import { FloatingFocusManager, FloatingPortal } from '@floating-ui/react'; import cl from 'clsx'; -import type { - UseFloatingReturn, - UseListNavigationProps, -} from '@floating-ui/react'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { flushSync } from 'react-dom'; import { Box } from '../../Box'; import type { FormFieldProps } from '../useFormField'; @@ -37,16 +13,17 @@ import { omit } from '../../../utilities'; import { Spinner } from '../../Spinner'; import type { Option } from './useCombobox'; -import useCombobox, { - isComboboxOption, - isInteractiveComboboxCustom, -} from './useCombobox'; +import useCombobox from './useCombobox'; import classes from './Combobox.module.css'; import ComboboxInput from './internal/ComboboxInput'; import ComboboxLabel from './internal/ComboboxLabel'; import ComboboxError from './internal/ComboboxError'; import ComboboxNative from './internal/ComboboxNative'; import ComboboxCustom from './Custom/Custom'; +import { useFloatingCombobox } from './useFloatingCombobox'; +import { useComboboxKeyboard } from './useComboboxKeyboard'; +import { ComboboxIdProvider } from './ComboboxIdContext'; +import { ComboboxContext } from './ComboboxContext'; export type ComboboxProps = { /** @@ -89,12 +66,6 @@ export type ComboboxProps = { * @default false */ hideChips?: boolean; - /** - * Label for the clear button - * @default 'Fjern alt' - * @deprecated Use `clearButtonLabel` instead - */ - cleanButtonLabel?: string; /** * Hides the clear button * @default false @@ -147,7 +118,7 @@ export type ComboboxProps = { FormFieldProps & Omit, 'size'>; -export const Combobox = forwardRef( +export const ComboboxComponent = forwardRef( ( { value, @@ -161,7 +132,6 @@ export const Combobox = forwardRef( disabled = false, readOnly = false, hideChips = false, - cleanButtonLabel = 'Fjern alt', clearButtonLabel = 'Fjern alt', hideClearButton = false, error, @@ -175,9 +145,7 @@ export const Combobox = forwardRef( style, loading, loadingLabel = 'Laster...', - filter = (inputValue, option) => { - return option.label.toLowerCase().startsWith(inputValue.toLowerCase()); - }, + filter, chipSrLabel = (option) => 'Slett ' + option.label, className, ...rest @@ -186,23 +154,21 @@ export const Combobox = forwardRef( ) => { const inputRef = useRef(null); const portalRef = useRef(null); + const listRef = useRef>([]); const listId = useId(); - const [open, setOpen] = useState(false); const [inputValue, setInputValue] = useState(rest.inputValue || ''); - const [activeIndex, setActiveIndex] = useState(null); const { selectedOptions, - setSelectedOptions, options, - optionsChildren, restChildren, - optionValues, + interactiveChildren, customIds, - prevSelectedHash, - setPrevSelectedHash, + filteredOptionsChildren, + filteredOptions, + setSelectedOptions, } = useCombobox({ children, inputValue, @@ -211,15 +177,18 @@ export const Combobox = forwardRef( initialValue, }); - const [activeDescendant, setActiveDescendant] = useState< - string | undefined - >(undefined); - - useEffect(() => { - if (rest.inputValue !== undefined) { - setInputValue(rest.inputValue); - } - }, [rest.inputValue]); + const { + open, + setOpen, + refs, + floatingStyles, + context, + getReferenceProps, + getFloatingProps, + getItemProps, + } = useFloatingCombobox({ + listRef, + }); const formFieldProps = useFormField( { @@ -234,111 +203,68 @@ export const Combobox = forwardRef( 'combobox', ); - const listRef = useRef>([]); - // if value is set, set input value to the label of the value useEffect(() => { if (value && value.length > 0 && !multiple) { - const option = options.find((option) => option.value === value[0]); + const option = options[value[0]]; setInputValue(option?.label || ''); } }, [multiple, value, options]); - // floating UI - const { refs, floatingStyles, context } = useFloating({ - open, - onOpenChange: (newOpen) => { - flushSync(() => { - if (refs.floating.current && !newOpen) { - refs.floating.current.scrollTop = 0; - } - setTimeout(() => { - setOpen(newOpen); - }, 1); - }); - }, - whileElementsMounted: (reference, floating, update) => { - autoUpdate(reference, floating, update); - return () => { - floating.scrollTop = 0; - }; - }, - middleware: [ - flip({ padding: 10 }), - floatingSize({ - apply({ rects, elements }) { - requestAnimationFrame(() => { - Object.assign(elements.floating.style, { - width: `calc(${rects.reference.width}px - calc(var(--fds-spacing-2) * 2))`, - maxHeight: `200px`, - }); - }); - }, - }), - offset(10), - ], - }); - - const role = useRole(context, { role: 'listbox' }); - const dismiss = useDismiss(context); - const listNav = useListNavigation(context, { - listRef, - activeIndex, - virtual: true, - scrollItemIntoView: true, - enabled: open, - }); - - const { getReferenceProps, getFloatingProps } = useInteractions([ - role, - dismiss, - listNav, - ]); - - // remove active index if combobox is closed - useEffect(() => { - if (!open) { - setActiveIndex(null); - } - }, [open]); - - // Send new value if option was clicked - useEffect(() => { - const selectedHash = JSON.stringify(selectedOptions); - if (prevSelectedHash === selectedHash) return; - - const values = selectedOptions.map((option) => option.value); - onValueChange?.(values); - setPrevSelectedHash(selectedHash); - }, [onValueChange, selectedOptions, prevSelectedHash, setPrevSelectedHash]); - useEffect(() => { - if (value && options.length > 0) { + if (value && Object.keys(options).length >= 0) { const updatedSelectedOptions = value.map((option) => { - const value = options.find((value) => value.value === option); - return value as Option; + const value = options[option]; + return value; }); - setSelectedOptions(updatedSelectedOptions); + setSelectedOptions( + updatedSelectedOptions.reduce<{ + [key: string]: Option; + }>((acc, value) => { + acc[value.value] = value; + return acc; + }, {}), + ); } - }, [multiple, prevSelectedHash, value, options, setSelectedOptions]); + }, [multiple, value, options, setSelectedOptions]); // handle click on option, either select or deselect - Handles single or multiple - const handleSelectOption = (option: Option) => { - // if option is already selected, remove it - if (value && value.includes(option.value)) { - setSelectedOptions((prev) => - prev.filter((i) => i.value !== option.value), - ); + const handleSelectOption = (args: { + option: Option | null; + remove?: boolean; + clear?: boolean; + }) => { + const { option, clear, remove } = args; + if (clear) { + setSelectedOptions({}); + setInputValue(''); + onValueChange?.([]); return; } + if (!option) return; + + if (remove) { + const newSelectedOptions = { ...selectedOptions }; + delete newSelectedOptions[option.value]; + setSelectedOptions(newSelectedOptions); + onValueChange?.(Object.keys(newSelectedOptions)); + return; + } + + const newSelectedOptions = { ...selectedOptions }; + if (multiple) { - setSelectedOptions([...selectedOptions, option]); + if (newSelectedOptions[option.value]) { + delete newSelectedOptions[option.value]; + } else { + newSelectedOptions[option.value] = option; + } setInputValue(''); inputRef.current?.focus(); } else { - setSelectedOptions([option]); + newSelectedOptions[option.value] = option; setInputValue(option?.label || ''); // move cursor to the end of the input setTimeout(() => { @@ -349,114 +275,34 @@ export const Combobox = forwardRef( }, 0); } + setSelectedOptions(newSelectedOptions); + console.log('calling new value with: ', Object.keys(newSelectedOptions)); + onValueChange?.(Object.keys(newSelectedOptions)); + !multiple && setOpen(false); refs.domReference.current?.focus(); }; const debouncedHandleSelectOption = useDebounce(handleSelectOption, 50); - // handle keyboard navigation in the list - const handleKeyDownFunc = (event: React.KeyboardEvent) => { - const navigateable = customIds.length + optionsChildren.length; - - if (formFieldProps.readOnly || disabled) return; - if (!event) return; - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - if (!open) setOpen(true); - setActiveIndex((prevActiveIndex) => { - if (prevActiveIndex === null) { - return 0; - } - - return Math.min(prevActiveIndex + 1, navigateable - 1); - }); - break; - case 'ArrowUp': - event.preventDefault(); - /* If we are on the first item, close */ - setActiveIndex((prevActiveIndex) => { - if (prevActiveIndex === 0) { - setOpen(false); - return null; - } - - if (prevActiveIndex === null) { - return null; - } - - return Math.max(prevActiveIndex - 1, 0); - }); - break; - case 'Enter': - event.preventDefault(); - if ( - activeIndex !== null && - (optionsChildren[activeIndex] || customIds.length > 0) - ) { - // check if we are in the custom components - if (activeIndex <= customIds.length) { - // send `onSelect` event to the custom component - const selectedId = customIds[activeIndex]; - const selectedComponent = restChildren.find( - (component) => - isInteractiveComboboxCustom(component) && - component.props?.id === selectedId, - ); - - if ( - isInteractiveComboboxCustom(selectedComponent) && - selectedComponent.props.onSelect - ) { - selectedComponent.props.onSelect(); - } - } - - // if we are in the options, find the actual index - const valueIndex = activeIndex - customIds.length; - - const child = optionsChildren[valueIndex]; - if (isComboboxOption(child)) { - const props = child.props; - const option = options.find( - (option) => option.value === props.value, - ); - - if (!multiple) { - // check if option is already selected, if so, deselect it - if (selectedOptions.find((i) => i.value === option?.value)) { - setSelectedOptions([]); - setInputValue(''); - return; - } - } - - debouncedHandleSelectOption(option as Option); - } - } - break; - - case 'Backspace': - if (inputValue === '' && multiple && selectedOptions.length > 0) { - setSelectedOptions((prev) => prev.slice(0, prev.length - 1)); - } - // if we are in single mode, we need to set activeValue to null - if (!multiple) { - setSelectedOptions([]); - } - break; - - default: - break; - } - }; - - const handleKeyDown = useDebounce(handleKeyDownFunc, 20); + const handleKeyDown = useComboboxKeyboard({ + filteredOptions, + selectedOptions, + readOnly: formFieldProps.readOnly || false, + disabled: disabled, + multiple, + inputValue, + options, + open, + interactiveChildren, + setOpen, + setInputValue, + handleSelectOption: debouncedHandleSelectOption, + }); const rowVirtualizer = useVirtualizer({ - count: optionsChildren.length, - getScrollElement: () => refs.floating.current, + count: Object.keys(filteredOptionsChildren).length, + getScrollElement: () => (virtual ? refs.floating.current : null), estimateSize: () => 70, measureElement: (elem) => { return elem.getBoundingClientRect().height; @@ -471,42 +317,27 @@ export const Combobox = forwardRef( options, selectedOptions, multiple, - activeIndex, disabled, readOnly, open, inputRef, refs, inputValue, - activeDescendant, - error, formFieldProps, - name, htmlSize, - optionValues, - hideChips, - clearButtonLabel: cleanButtonLabel || clearButtonLabel, - hideClearButton, - listId, + clearButtonLabel, + customIds, + filteredOptions, setInputValue, - setActiveIndex, - handleKeyDown, setOpen, getReferenceProps, - setSelectedOptions, - /* Recieves index of option, and the ID of the button element */ - setActiveOption: (index: number, id: string) => { - if (readOnly) return; - if (disabled) return; - setActiveIndex(index); - setActiveDescendant(id); - }, + getItemProps, /* Recieves the value of the option, and searches for it in our values lookup */ onOptionClick: (value: string) => { if (readOnly) return; if (disabled) return; - const option = options.find((option) => option.value === value); - debouncedHandleSelectOption(option as Option); + const option = options[value]; + debouncedHandleSelectOption({ option: option }); }, handleSelectOption: debouncedHandleSelectOption, chipSrLabel, @@ -542,6 +373,11 @@ export const Combobox = forwardRef( /> ( transform: `translateY(${virtualRow.start}px)`, }} > - {optionsChildren[virtualRow.index]} + {filteredOptionsChildren[virtualRow.index]} ))} @@ -615,7 +451,7 @@ export const Combobox = forwardRef( <> {/* Add the rest of the children */} {restChildren} - {!virtual && optionsChildren} + {!virtual && filteredOptionsChildren} )} @@ -627,46 +463,15 @@ export const Combobox = forwardRef( }, ); -type ComboboxContextType = { - multiple: NonNullable; - disabled: NonNullable; - readOnly: NonNullable; - name: ComboboxProps['name']; - error: ComboboxProps['error']; - htmlSize: ComboboxProps['htmlSize']; - hideChips: NonNullable; - clearButtonLabel: NonNullable; - hideClearButton: NonNullable; - options: Option[]; - selectedOptions: Option[]; - size: NonNullable; - formFieldProps: ReturnType; - refs: UseFloatingReturn['refs']; - inputRef: React.RefObject; - activeIndex: number | null; - open: boolean; - inputValue: string; - activeDescendant: string | undefined; - optionValues: string[]; - listId: string; - setInputValue: React.Dispatch>; - setOpen: (open: boolean) => void; - handleKeyDown: (event: React.KeyboardEvent) => void; - setActiveIndex: (index: number | null) => void; - setActiveOption: (index: number, id: string) => void; - getReferenceProps: ( - props?: Record, - ) => Record; - onOptionClick: (value: string) => void; - setSelectedOptions: React.Dispatch>; - chipSrLabel: NonNullable; - handleSelectOption: (option: Option) => void; - listRef: UseListNavigationProps['listRef']; - forwareddRef: React.Ref; -}; - -export const ComboboxContext = createContext( - undefined, +export const Combobox = forwardRef( + (props, ref) => ( + + + + ), ); Combobox.displayName = 'Combobox'; diff --git a/packages/react/src/components/form/Combobox/ComboboxContext.tsx b/packages/react/src/components/form/Combobox/ComboboxContext.tsx new file mode 100644 index 0000000000..3a3dbc5d87 --- /dev/null +++ b/packages/react/src/components/form/Combobox/ComboboxContext.tsx @@ -0,0 +1,53 @@ +import type { + UseFloatingReturn, + UseListNavigationProps, + useInteractions, +} from '@floating-ui/react'; +import { createContext } from 'react'; + +import type { useFormField } from '../useFormField'; + +import type { ComboboxProps } from './Combobox'; +import type { Option } from './useCombobox'; +import type useCombobox from './useCombobox'; + +export type ComboboxContextType = { + multiple: NonNullable; + disabled: NonNullable; + readOnly: NonNullable; + htmlSize: ComboboxProps['htmlSize']; + clearButtonLabel: NonNullable; + filteredOptions: ReturnType['filteredOptions']; + options: { + [key: string]: Option; + }; + selectedOptions: { + [key: string]: Option; + }; + size: NonNullable; + formFieldProps: ReturnType; + refs: UseFloatingReturn['refs']; + inputRef: React.RefObject; + open: boolean; + inputValue: string; + customIds: string[]; + setInputValue: React.Dispatch>; + setOpen: (open: boolean) => void; + getReferenceProps: ( + props?: Record, + ) => Record; + getItemProps: ReturnType['getItemProps']; + onOptionClick: (value: string) => void; + chipSrLabel: NonNullable; + handleSelectOption: (args: { + option: Option | null; + remove?: boolean; + clear?: boolean; + }) => void; + listRef: UseListNavigationProps['listRef']; + forwareddRef: React.Ref; +}; + +export const ComboboxContext = createContext( + undefined, +); diff --git a/packages/react/src/components/form/Combobox/ComboboxIdContext.tsx b/packages/react/src/components/form/Combobox/ComboboxIdContext.tsx new file mode 100644 index 0000000000..73546ba56b --- /dev/null +++ b/packages/react/src/components/form/Combobox/ComboboxIdContext.tsx @@ -0,0 +1,64 @@ +import type { Dispatch } from 'react'; +import { createContext, useContext, useReducer } from 'react'; + +type ComboboxIdContextType = { + activeIndex: number; +}; + +export const ComboboxIdContext = createContext({ + activeIndex: 0, +}); + +type SetActiveIndexAction = { + type: 'SET_ACTIVE_INDEX'; + payload: number; +}; + +type ComboboxIdReducerAction = SetActiveIndexAction; + +export const ComboboxIdReducer = ( + state: ComboboxIdContextType, + action: ComboboxIdReducerAction, +) => { + switch (action.type) { + case 'SET_ACTIVE_INDEX': + return { + ...state, + activeIndex: action.payload, + }; + default: + return state; + } +}; + +export const ComboboxIdDispatch = createContext< + Dispatch +>(() => { + throw new Error('ComboboxIdDispatch must be used within a provider'); +}); + +export const ComboboxIdProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [state, dispatch] = useReducer(ComboboxIdReducer, { + activeIndex: 0, + }); + + return ( + + + {children} + + + ); +}; + +export function useComboboxIdDispatch() { + return useContext(ComboboxIdDispatch); +} + +export function useComboboxId() { + return useContext(ComboboxIdContext); +} diff --git a/packages/react/src/components/form/Combobox/Custom/Custom.tsx b/packages/react/src/components/form/Combobox/Custom/Custom.tsx index 984a1ab826..16da38bd41 100644 --- a/packages/react/src/components/form/Combobox/Custom/Custom.tsx +++ b/packages/react/src/components/form/Combobox/Custom/Custom.tsx @@ -2,9 +2,11 @@ import { forwardRef, useContext, useId, useMemo } from 'react'; import type * as React from 'react'; import cl from 'clsx'; import { Slot } from '@radix-ui/react-slot'; +import { useMergeRefs } from '@floating-ui/react'; -import { ComboboxContext } from '../Combobox'; +import { ComboboxContext } from '../ComboboxContext'; import { omit } from '../../../../utilities'; +import { useComboboxId } from '../ComboboxIdContext'; import classes from './Custom.module.css'; @@ -51,34 +53,38 @@ export const ComboboxCustom = forwardRef( const randomId = useId(); + const { activeIndex } = useComboboxId(); const context = useContext(ComboboxContext); + if (!context) { throw new Error('ComboboxCustom must be used within a Combobox'); } - const { size, activeIndex, optionValues, setActiveIndex } = context; + const { size, customIds, listRef, getItemProps } = context; const index = useMemo( - () => id && optionValues.indexOf(id), - [optionValues, id], + () => (id && customIds.indexOf(id)) || 0, + [id, customIds], ); + const combinedRef = useMergeRefs([ + (node: HTMLElement | null) => { + listRef.current[index] = node; + }, + ref, + ]); + return ( { - typeof index === 'number' && setActiveIndex(index); - }} // Set active index on hover - onFocus={() => { - typeof index === 'number' && setActiveIndex(index); - }} {...omit(['interactive'], rest)} + {...omit(['onClick', 'onPointerLeave'], getItemProps())} /> ); }, diff --git a/packages/react/src/components/form/Combobox/Empty/Empty.tsx b/packages/react/src/components/form/Combobox/Empty/Empty.tsx index 64c6ac9822..edf6556af1 100644 --- a/packages/react/src/components/form/Combobox/Empty/Empty.tsx +++ b/packages/react/src/components/form/Combobox/Empty/Empty.tsx @@ -2,7 +2,7 @@ import { forwardRef, useContext } from 'react'; import type * as React from 'react'; import cl from 'clsx'; -import { ComboboxContext } from '../Combobox'; +import { ComboboxContext } from '../ComboboxContext'; import classes from './Empty.module.css'; @@ -15,10 +15,10 @@ export const ComboboxEmpty = forwardRef( throw new Error('ComboboxEmpty must be used within a Combobox'); } - const { optionValues, size } = context; + const { filteredOptions, size } = context; return ( - optionValues.length === 0 && ( + filteredOptions.length === 0 && (
; -export const ComboboxOption = forwardRef< - HTMLButtonElement, - ComboboxOptionProps ->(({ value, description, children, className, ...rest }, ref) => { - const labelId = useId(); - const generatedId = useId(); +export const ComboboxOption = memo( + forwardRef( + ({ value, description, children, className, ...rest }, forwardedRef) => { + const labelId = useId(); - const context = useContext(ComboboxContext); - if (!context) { - throw new Error('ComboboxOption must be used within a Combobox'); - } - const { - selectedOptions, - activeIndex, - setActiveOption, - onOptionClick, - size, - listRef, - optionValues, - multiple, - } = context; + const { id, ref, selected, active, onOptionClick } = useComboboxOption({ + id: rest.id, + ref: forwardedRef, + value, + }); - const index = useMemo( - () => optionValues.indexOf(value), - [optionValues, value], - ); + const context = useContext(ComboboxContext); + if (!context) { + throw new Error('ComboboxOption must be used within a Combobox'); + } + const { size, multiple, getItemProps } = context; - const combinedRef = useMergeRefs([ - (node: HTMLElement | null) => { - listRef.current[index] = node; - }, - ref, - ]); - - if (index === -1) { - throw new Error('Internal error: ComboboxOption did not find index'); - } - - const selected = selectedOptions.find((option) => option.value === value); + const props = getItemProps(); - useEffect(() => { - if (activeIndex === index) setActiveOption(index, rest.id || generatedId); - }, [activeIndex, generatedId, index, rest.id, setActiveOption]); - - const onOptionClickDebounced = useDebounce(() => onOptionClick(value), 50); - - return ( - - ); -}); + return ( + + ); + }, + ), +); ComboboxOption.displayName = 'ComboboxOption'; diff --git a/packages/react/src/components/form/Combobox/Option/useComboboxOption.tsx b/packages/react/src/components/form/Combobox/Option/useComboboxOption.tsx new file mode 100644 index 0000000000..27fa8d380d --- /dev/null +++ b/packages/react/src/components/form/Combobox/Option/useComboboxOption.tsx @@ -0,0 +1,70 @@ +import { useContext, useEffect, useId, useMemo } from 'react'; +import { useMergeRefs } from '@floating-ui/react'; + +import { ComboboxContext } from '../ComboboxContext'; +import useDebounce from '../../../../utilities/useDebounce'; +import { useComboboxId, useComboboxIdDispatch } from '../ComboboxIdContext'; + +type UseComboboxOptionProps = { + id?: string; + ref: React.Ref; + value: string; +}; + +export default function useComboboxOption({ + id, + ref, + value, +}: UseComboboxOptionProps) { + const generatedId = useId(); + const newId = id || generatedId; + + const context = useContext(ComboboxContext); + const { activeIndex } = useComboboxId(); + const dispatch = useComboboxIdDispatch(); + if (!context) { + throw new Error('ComboboxOption must be used within a Combobox'); + } + const { + selectedOptions, + onOptionClick, + listRef, + customIds, + filteredOptions, + } = context; + + const index = useMemo( + () => filteredOptions.indexOf(value) + customIds.length, + [customIds.length, filteredOptions, value], + ); + + const combinedRef = useMergeRefs([ + (node: HTMLElement | null) => { + listRef.current[index] = node; + }, + ref, + ]); + + if (index === -1) { + throw new Error('Internal error: ComboboxOption did not find index'); + } + + const selected = selectedOptions[value]; + const active = activeIndex === index; + + useEffect(() => { + if (active) { + dispatch?.({ type: 'SET_ACTIVE_INDEX', payload: index }); + } + }, [generatedId, id, dispatch, active, index]); + + const onOptionClickDebounced = useDebounce(() => onOptionClick(value), 50); + + return { + id: newId, + ref: combinedRef, + selected, + active, + onOptionClick: onOptionClickDebounced, + }; +} diff --git a/packages/react/src/components/form/Combobox/internal/ComboboxChips.tsx b/packages/react/src/components/form/Combobox/internal/ComboboxChips.tsx index 8d114a6505..ad78056f8f 100644 --- a/packages/react/src/components/form/Combobox/internal/ComboboxChips.tsx +++ b/packages/react/src/components/form/Combobox/internal/ComboboxChips.tsx @@ -1,7 +1,7 @@ import { useContext } from 'react'; import { ChipRemovable } from '../../../Chip'; -import { ComboboxContext } from '../Combobox'; +import { ComboboxContext } from '../ComboboxContext'; export const ComboboxChips = () => { const context = useContext(ComboboxContext); @@ -15,17 +15,17 @@ export const ComboboxChips = () => { readOnly, disabled, selectedOptions, - setSelectedOptions, chipSrLabel, + handleSelectOption, inputRef, } = context; return ( <> - {selectedOptions.map((option) => { + {Object.keys(selectedOptions).map((value) => { return ( { @@ -33,9 +33,10 @@ export const ComboboxChips = () => { if (disabled) return; if (e.key === 'Enter') { e.stopPropagation(); - setSelectedOptions( - selectedOptions.filter((i) => i.value !== option.value), - ); + handleSelectOption({ + option: selectedOptions[value], + remove: true, + }); inputRef.current?.focus(); } }} @@ -43,17 +44,14 @@ export const ComboboxChips = () => { if (readOnly) return; if (disabled) return; /* If we click a chip, filter the active values and remove the one we clicked */ - setSelectedOptions( - selectedOptions.filter((i) => i.value !== option.value), - ); + handleSelectOption({ + option: selectedOptions[value], + remove: true, + }); }} - style={{ - /* We already set the opacity on Combobox */ - opacity: 1, - }} - aria-label={chipSrLabel(option)} + aria-label={chipSrLabel(selectedOptions[value])} > - {option.label} + {selectedOptions[value].label} ); })} diff --git a/packages/react/src/components/form/Combobox/internal/ComboboxClearButton.tsx b/packages/react/src/components/form/Combobox/internal/ComboboxClearButton.tsx index 8ef749ad79..ecade07782 100644 --- a/packages/react/src/components/form/Combobox/internal/ComboboxClearButton.tsx +++ b/packages/react/src/components/form/Combobox/internal/ComboboxClearButton.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react'; import { XMarkIcon } from '@navikt/aksel-icons'; import cl from 'clsx'; -import { ComboboxContext } from '../Combobox'; +import { ComboboxContext } from '../ComboboxContext'; import classes from '../Combobox.module.css'; export const ComboboxClearButton = () => { @@ -12,15 +12,8 @@ export const ComboboxClearButton = () => { throw new Error('ComboboxContext is missing'); } - const { - size, - readOnly, - disabled, - clearButtonLabel, - inputRef, - setSelectedOptions, - setInputValue, - } = context; + const { size, readOnly, disabled, clearButtonLabel, handleSelectOption } = + context; return (