diff --git a/packages/ods-react/src/components/combobox/.storybook/main.ts b/packages/ods-react/src/components/combobox/.storybook/main.ts new file mode 100644 index 0000000000..c8ecf218d5 --- /dev/null +++ b/packages/ods-react/src/components/combobox/.storybook/main.ts @@ -0,0 +1,26 @@ +import { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + core: { + disableTelemetry: true, + disableWhatsNewNotifications: true, + }, + docs: { + autodocs: false, + }, + framework: '@storybook/react-vite', + previewHead: (head) => ` + ${head} + + `, + stories: [ + '../src/dev.stories.tsx', + '../tests/**/*.stories.tsx', + ], +}; + +export default config; diff --git a/packages/ods-react/src/components/combobox/.storybook/manager.ts b/packages/ods-react/src/components/combobox/.storybook/manager.ts new file mode 100644 index 0000000000..7007ab574d --- /dev/null +++ b/packages/ods-react/src/components/combobox/.storybook/manager.ts @@ -0,0 +1,10 @@ +import { addons } from '@storybook/manager-api'; + +addons.register('custom-panel', (api) => { + api.togglePanel(false); +}); + +addons.setConfig({ + enableShortcuts: false, + showToolbar: true, +}); diff --git a/packages/ods-react/src/components/combobox/.storybook/preview.ts b/packages/ods-react/src/components/combobox/.storybook/preview.ts new file mode 100644 index 0000000000..db87560d8c --- /dev/null +++ b/packages/ods-react/src/components/combobox/.storybook/preview.ts @@ -0,0 +1,8 @@ +import { type Preview } from '@storybook/react'; +import '@ovhcloud/ods-themes/default'; + +const preview: Preview = { + parameters: {}, +}; + +export default preview; diff --git a/packages/ods-react/src/components/combobox/jest-puppeteer.config.ts b/packages/ods-react/src/components/combobox/jest-puppeteer.config.ts new file mode 100644 index 0000000000..73ec1b96e5 --- /dev/null +++ b/packages/ods-react/src/components/combobox/jest-puppeteer.config.ts @@ -0,0 +1,17 @@ +const isCI = !!process.env.CI; + +export default { + launch: { + headless: isCI, + slowMo: isCI ? 0 : 300, + product: 'chrome', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + "--disable-dev-shm-usage", + "--disable-accelerated-2d-canvas", + "--disable-gpu", + '--font-render-hinting=none', + ], + }, +}; diff --git a/packages/ods-react/src/components/combobox/jest.config.ts b/packages/ods-react/src/components/combobox/jest.config.ts new file mode 100644 index 0000000000..aa96384391 --- /dev/null +++ b/packages/ods-react/src/components/combobox/jest.config.ts @@ -0,0 +1,26 @@ +const baseOption = { + collectCoverage: false, + testPathIgnorePatterns: [ + 'node_modules/', + 'dist/', + ], + testRegex: 'tests\\/.*\\.spec\\.ts$', + transform: { + '\\.(ts|tsx)$': 'ts-jest', + }, + verbose: true, +}; + +export default !!process.env.E2E ? + { + ...baseOption, + preset: 'jest-puppeteer', + testRegex: 'tests\\/.*\\.e2e\\.ts$', + testTimeout: 60000, + } : { + ...baseOption, + transform: { + ...baseOption.transform, + '\\.scss$': 'jest-transform-stub', + } + }; diff --git a/packages/ods-react/src/components/combobox/modules.d.ts b/packages/ods-react/src/components/combobox/modules.d.ts new file mode 100644 index 0000000000..875203d567 --- /dev/null +++ b/packages/ods-react/src/components/combobox/modules.d.ts @@ -0,0 +1,2 @@ +declare module '*.css'; +declare module '*.scss'; diff --git a/packages/ods-react/src/components/combobox/package.json b/packages/ods-react/src/components/combobox/package.json new file mode 100644 index 0000000000..e79f5426cf --- /dev/null +++ b/packages/ods-react/src/components/combobox/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ovhcloud/ods-react-combobox", + "version": "18.6.2", + "private": true, + "description": "ODS React Combobox component", + "type": "module", + "main": "dist/index.js", + "scripts": { + "clean": "rimraf documentation node_modules", + "doc": "typedoc", + "lint:a11y": "eslint --config ../../../../../.eslintrc-a11y 'src/**/*.{js,ts,tsx}' --ignore-pattern '*.stories.tsx'", + "lint:scss": "stylelint --aei 'src/components/**/*.scss'", + "lint:ts": "eslint '{src,tests}/**/*.{js,ts,tsx}' --ignore-pattern '*.stories.tsx'", + "start": "npm run start:storybook", + "start:storybook": "storybook dev -p 3000 --no-open", + "test:e2e": "E2E=true start-server-and-test 'npm run start:storybook' 3000 'jest -i --detectOpenHandles'", + "test:e2e:ci": "CI=true npm run test:e2e", + "test:spec": "jest 'tests/.*.spec.ts$' --passWithNoTests", + "test:spec:ci": "npm run test:spec" + } +} diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-content/ComboboxContent.tsx b/packages/ods-react/src/components/combobox/src/components/combobox-content/ComboboxContent.tsx new file mode 100644 index 0000000000..50c29c575e --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-content/ComboboxContent.tsx @@ -0,0 +1,83 @@ +import type { FC } from 'react'; +import { Combobox as VendorCombobox, useComboboxContext } from '@ark-ui/react/combobox'; +import { Portal } from '@ark-ui/react/portal'; +import classNames from 'classnames'; +import { type JSX, forwardRef, useRef } from 'react'; +import { useCombobox } from '../../context/useCombobox'; +import { ComboboxGroup } from '../combobox-group/ComboboxGroup'; +import { ComboboxOption } from '../combobox-option/ComboboxOption'; +import style from './comboboxContent.module.scss'; + +interface ComboboxContentProp { + addNewElementLabel?: string; + className?: string; + + [ key: string ]: unknown; +} + +const ComboboxContent: FC = forwardRef(({ + className, + ...props +}, ref): JSX.Element => { + const { collection } = useComboboxContext(); + const localRef = useRef(null); + const contentRef = (ref as React.RefObject) || localRef; + const { addNewElementLabel, customOptionRenderer, noResultLabel } = useCombobox(); + + const hasEnabledOption = collection.items.some( + (item: Record) => typeof item === 'object' && item !== null && !('disabled' in item && item.disabled) && !('isNew' in item && item.isNew), + ); + + return ( + + + + + { collection.size > 0 && ([...collection][ 0 ]?.isNew) ? ( + + + + ) : null } + { collection.group().map(([groupLabel, groupItems]) => ( + + { groupItems + .filter((item) => !item.isNew) + .map((item) => ( + + )) } + + )) } + + + { !hasEnabledOption ? ( +
{ noResultLabel }
+ ) : null } + +
+
+
+ ); +}, +); + +ComboboxContent.displayName = 'ComboboxContent'; + +export { + ComboboxContent, + type ComboboxContentProp, +}; diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-content/comboboxContent.module.scss b/packages/ods-react/src/components/combobox/src/components/combobox-content/comboboxContent.module.scss new file mode 100644 index 0000000000..d829c24381 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-content/comboboxContent.module.scss @@ -0,0 +1,25 @@ +@use '../../../../../style/focus'; +@use '../../../../../style/overlay'; + +@layer ods-organisms { + .combobox-content { + box-sizing: border-box; + z-index: overlay.$ods-overlay-select-z-index; + margin: 0; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: var(--ods-border-radius-sm); + background: var(--ods-color-primary-000); + padding: 0; + color: var(--ods-color-text); + + &__empty { + display: flex; + align-items: center; + padding: 0 8px; + min-height: 32px; + color: var(--ods-color-text); + } + + } + +} diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-control/ComboboxControl.tsx b/packages/ods-react/src/components/combobox/src/components/combobox-control/ComboboxControl.tsx new file mode 100644 index 0000000000..b8ccdb9117 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-control/ComboboxControl.tsx @@ -0,0 +1,88 @@ +import { Combobox as VendorCombobox, useComboboxContext as useVendorComboboxContext } from '@ark-ui/react/combobox'; +import classNames from 'classnames'; +import { type ComponentPropsWithRef, type FC, type JSX, forwardRef } from 'react'; +import { Input } from '../../../../input/src'; +import { useCombobox } from '../../context/useCombobox'; +import style from './comboboxControl.module.scss'; + +interface ComboboxControlProp extends ComponentPropsWithRef<'button'> { + clearable?: boolean; + loading?: boolean; + placeholder?: string; +} + +const ComboboxControl: FC = forwardRef(({ + className, + clearable = false, + loading = false, + placeholder, + ...props +}, ref): JSX.Element | null => { + const vendorContext = useVendorComboboxContext(); + + const context = useCombobox(); + const { setValue, setInputValue, inputValue } = context; + + const { getContentProps } = vendorContext; + const contentProps = getContentProps() as { + 'data-placement'?: 'bottom' | 'top'; + 'data-state'?: 'open' | 'closed'; + }; + const placement = contentProps[ 'data-placement' ] as 'bottom' | 'top' | undefined; + const isOpen = contentProps[ 'data-state' ] === 'open'; + + const handleInputKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter') { + const hasHighlighted = !!document.querySelector('[role="option"][data-highlighted]'); + if (!hasHighlighted) { + event.preventDefault(); + event.stopPropagation(); + } + } + }; + + const handleClear = (): void => { + setValue && setValue([]); + setInputValue && setInputValue(''); + }; + + const handleInputChange = (event: React.ChangeEvent): void => { + setInputValue && setInputValue(event.target.value); + }; + + return ( + + + + + + + + ); +}); + +ComboboxControl.displayName = 'ComboboxControl'; + +export { + ComboboxControl, + type ComboboxControlProp, +}; diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-control/comboboxControl.module.scss b/packages/ods-react/src/components/combobox/src/components/combobox-control/comboboxControl.module.scss new file mode 100644 index 0000000000..bb064c1816 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-control/comboboxControl.module.scss @@ -0,0 +1,46 @@ +@use '../../../../../style/focus'; +@use '../../../../../style/input'; +@use '../../../../../style/state'; + +@layer ods-organisms { + .combobox-control { + display: flex; + margin: 0; + border: var(--ods-border-width-sm) solid var(--ods-color-form-element-border-default); + border-radius: var(--ods-border-radius-sm); + width: 100%; + + &--open-bottom { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + &--open-top { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &__trigger { + border: none; + background-color: transparent; + padding: 0; + width: 100%; + } + + &__input { + outline: none; + border: none; + background: transparent; + width: 100%; + } + + &[data-focus] { + @include focus.ods-focus(); + } + + &[data-disabled] { + @include state.ods-is-disabled(); + } + } + +} diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-group/ComboboxGroup.tsx b/packages/ods-react/src/components/combobox/src/components/combobox-group/ComboboxGroup.tsx new file mode 100644 index 0000000000..da32477773 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-group/ComboboxGroup.tsx @@ -0,0 +1,32 @@ +import { Combobox as VendorCombobox } from '@ark-ui/react/combobox'; +import classNames from 'classnames'; +import { type FC, type ReactNode } from 'react'; +import style from './comboboxGroup.module.scss'; + +interface ComboboxGroupProp { + children: ReactNode; + className?: string; + groupLabel?: string; +} + +const ComboboxGroup: FC = ({ + children, + className, + groupLabel, +}) => ( + + { groupLabel && ( + + { groupLabel } + + ) } + { children } + +); + +ComboboxGroup.displayName = 'ComboboxGroup'; + +export { + ComboboxGroup, + type ComboboxGroupProp, +}; diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-group/comboboxGroup.module.scss b/packages/ods-react/src/components/combobox/src/components/combobox-group/comboboxGroup.module.scss new file mode 100644 index 0000000000..87490dc47e --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-group/comboboxGroup.module.scss @@ -0,0 +1,21 @@ +@use '../../../../../style/state'; + +@layer ods-organisms { + .combobox-group { + margin: 0; + padding: 0; + + &__label { + display: flex; + align-items: center; + padding: 0 8px; + min-height: 32px; + color: var(--ods-color-heading); + font-weight: 600; + } + + &[data-disabled] { + @include state.ods-is-disabled(); + } + } +} diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-label/ComboboxLabel.tsx b/packages/ods-react/src/components/combobox/src/components/combobox-label/ComboboxLabel.tsx new file mode 100644 index 0000000000..d0301a8181 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-label/ComboboxLabel.tsx @@ -0,0 +1,29 @@ +import { Combobox as VendorCombobox } from '@ark-ui/react/combobox'; +import classNames from 'classnames'; +import { type ComponentPropsWithRef, type FC, type JSX, forwardRef } from 'react'; +import style from './comboboxLabel.module.scss'; + +interface ComboboxLabelProp extends ComponentPropsWithRef<'label'> { +} + +const ComboboxLabel: FC = forwardRef(({ + children, + className, + ...props +}, ref): JSX.Element => { + return ( + + { children } + + ); +}); + +ComboboxLabel.displayName = 'ComboboxLabel'; + +export { + ComboboxLabel, + type ComboboxLabelProp, +}; diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-label/comboboxLabel.module.scss b/packages/ods-react/src/components/combobox/src/components/combobox-label/comboboxLabel.module.scss new file mode 100644 index 0000000000..2e23333d08 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-label/comboboxLabel.module.scss @@ -0,0 +1,7 @@ +@use '../../../../../style/text'; + +@layer ods { + .combobox-label { + @include text.ods-text-label(); + } +} diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-option/ComboboxOption.tsx b/packages/ods-react/src/components/combobox/src/components/combobox-option/ComboboxOption.tsx new file mode 100644 index 0000000000..fc29e1d78b --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-option/ComboboxOption.tsx @@ -0,0 +1,65 @@ +import type { ComboboxCustomOptionRendererArg } from '../../context/useCombobox'; +import { Combobox as VendorCombobox, useComboboxContext } from '@ark-ui/react/combobox'; +import classNames from 'classnames'; +import { type FC, type JSX } from 'react'; +import { type ComboboxOptionItem, useCombobox } from '../../context/useCombobox'; +import { highlightInElement } from '../../controller/combobox'; +import style from './comboboxOption.module.scss'; + +interface ComboboxOptionProp { + addNewElementLabel?: string; + className?: string; + customOptionRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element; + isInGroup?: boolean; + item: ComboboxOptionItem; +} + +const ComboboxOption: FC = ({ + addNewElementLabel, + className, + customOptionRenderer, + isInGroup = false, + item, +}): JSX.Element => { + const { highlightResults } = useCombobox(); + const { inputValue } = useComboboxContext(); + + let content: React.ReactNode; + if (item.isNew) { + content = addNewElementLabel + item.value; + } else if (customOptionRenderer) { + const rendered = customOptionRenderer({ + customData: item.customRendererData, + label: item.label, + }); + content = (highlightResults && inputValue) + ? highlightInElement(rendered, inputValue) + : rendered; + } else { + content = (highlightResults && inputValue) + ? highlightInElement(item.label, inputValue) + : item.label; + } + + return ( + + + { content } + + + ); +}; + +ComboboxOption.displayName = 'ComboboxOption'; + +export { + ComboboxOption, + type ComboboxOptionProp, +}; diff --git a/packages/ods-react/src/components/combobox/src/components/combobox-option/comboboxOption.module.scss b/packages/ods-react/src/components/combobox/src/components/combobox-option/comboboxOption.module.scss new file mode 100644 index 0000000000..3ea9a3f59b --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox-option/comboboxOption.module.scss @@ -0,0 +1,45 @@ +@use '../../../../../style/input'; +@use '../../../../../style/state'; + +@layer ods { + .combobox-option { + display: flex; + gap: 8px; + align-items: center; + cursor: pointer; + padding: 0 8px; + min-height: 32px; + color: var(--ods-color-text); + + &[data-highlighted] { + background-color: var(--ods-color-primary-100); + } + + &[data-disabled] { + display: none; + } + + &[data-state="checked"] { + &[data-highlighted] { + background-color: var(--ods-color-primary-100); + } + } + + &--multiple { + display: grid; + grid-template-columns: max-content 1fr; + } + + &--in-group { + padding-left: 16px; + } + + &--add { + border-bottom: 1px solid var(--ods-color-border, #e0e0e0); + } + + &--highlighted { + font-weight: bold; + } + } +} diff --git a/packages/ods-react/src/components/combobox/src/components/combobox/Combobox.tsx b/packages/ods-react/src/components/combobox/src/components/combobox/Combobox.tsx new file mode 100644 index 0000000000..e1a7e90b57 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/components/combobox/Combobox.tsx @@ -0,0 +1,148 @@ +import { + type ComboboxValueChangeDetails, + Combobox as VendorCombobox, + createListCollection, +} from '@ark-ui/react/combobox'; +import { type ComponentPropsWithRef, type FC, type JSX, forwardRef, useEffect, useMemo, useState } from 'react'; +import { + type ComboboxCustomOptionRendererArg, + type ComboboxItem, + type ComboboxOptionItem, + ComboboxProvider, +} from '../../context/useCombobox'; +import { createValueToLabelMap, getFlatItemsWithDisabled } from '../../controller/combobox'; + +type ComboboxProp = Omit, 'onSelect'> & { + addNewElementLabel?: string; + allowNewElement?: boolean; + customOptionRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element; + defaultValue?: string[]; + disabled?: boolean; + highlightResults?: boolean; + invalid?: boolean; + items: ComboboxItem[]; + name?: string; + noResultLabel?: string; + onInputValueChange?: (details: { inputValue: string }) => void; + onValueChange?: (details: ComboboxValueChangeDetails) => void; + readOnly?: boolean; + required?: boolean; + value?: string[]; +}; + +const Combobox: FC = forwardRef(({ + addNewElementLabel = 'Add ', + allowNewElement = true, + children, + className, + customOptionRenderer, + defaultValue, + disabled = false, + highlightResults = false, + invalid, + items = [], + name, + noResultLabel = 'No results found', + onInputValueChange, + onValueChange, + readOnly = false, + required, + value, + ...props +}, ref): JSX.Element => { + const [inputValue, setInputValue] = useState(''); + const [uncontrolledValues, setUncontrolledValues] = useState(defaultValue ?? []); + + const isControlled = value !== undefined; + const selectedValues = isControlled ? value : uncontrolledValues; + + const flatItems = useMemo(() => { + return getFlatItemsWithDisabled(items, inputValue, { + allowNewElement, + customRenderer: customOptionRenderer, + selectedValues, + }); + }, [allowNewElement, customOptionRenderer, inputValue, items, selectedValues]); + + const collection = useMemo(() => { + return createListCollection({ + groupBy: (item) => item.group || '', + items: flatItems, + }); + }, [flatItems]); + + const valueToLabelMap = useMemo(() => createValueToLabelMap(items), [items]); + + useEffect(() => { + if (selectedValues && selectedValues.length > 0) { + const val = selectedValues[ 0 ]; + const label = valueToLabelMap.get(val) || val; + setInputValue(label); + } else { + setInputValue(''); + } + }, [selectedValues, valueToLabelMap]); + + const handleInputValueChange = (details: { inputValue: string }): void => { + setInputValue(details.inputValue); + onInputValueChange?.(details); + }; + + const handleValueChange = (details: ComboboxValueChangeDetails): void => { + if (!isControlled) { + setUncontrolledValues(details.value); + } + onValueChange?.(details); + }; + + const setValue = (newValues: string[]): void => { + if (!isControlled) { + setUncontrolledValues(newValues); + } + + onValueChange?.({ + items: flatItems.filter((item) => newValues.includes(item.value)), + value: newValues, + }); + }; + + return ( + + + { children } + + + ); +}); + +Combobox.displayName = 'Combobox'; + +export { + Combobox, + type ComboboxProp, +}; diff --git a/packages/ods-react/src/components/combobox/src/context/useCombobox.tsx b/packages/ods-react/src/components/combobox/src/context/useCombobox.tsx new file mode 100644 index 0000000000..053ac7b7c4 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/context/useCombobox.tsx @@ -0,0 +1,97 @@ +import { type ReactNode, createContext, useContext } from 'react'; + +interface ComboboxContextType { + addNewElementLabel?: string, + customOptionRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element; + getContentProps?: () => Record; + highlightResults?: boolean; + inputValue?: string; + noResultLabel?: string; + setInputValue?: (value: string) => void; + setValue?: (value: string[]) => void; +} + +type ComboboxCustomGroupRendererArg = { + label: string; + customData?: Record; +}; + +type ComboboxCustomOptionRendererArg = { + label: string; + customData?: Record; +}; + +type ComboboxGroupItem = { + customRendererData?: Record; + disabled?: boolean; + label: string; + options: ComboboxOptionItem[]; +}; + +type ComboboxItem = ComboboxOptionItem | ComboboxGroupItem; + +type ComboboxOptionItem = { + label: string; + value: string; + customRendererData?: Record; + disabled?: boolean; + /** @internal */ + isInGroup?: boolean; + /** @internal */ + isNew?: boolean; +}; + +interface ComboboxProviderProps { + addNewElementLabel?: string, + children: ReactNode; + customOptionRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element; + getContentProps?: () => Record; + highlightResults?: boolean; + inputValue?: string; + noResultLabel?: string; + setInputValue?: (value: string) => void; + setValue?: (value: string[]) => void; +} + +const ComboboxContext = createContext({}); + +function useCombobox(): ComboboxContextType { + return useContext(ComboboxContext); +} + +const ComboboxProvider: React.FC = ({ + addNewElementLabel, + children, + customOptionRenderer, + getContentProps, + highlightResults, + inputValue, + noResultLabel, + setInputValue, + setValue, +}) => { + return ( + + { children } + + ); +}; + +export { + ComboboxProvider, + type ComboboxCustomGroupRendererArg, + type ComboboxCustomOptionRendererArg, + type ComboboxGroupItem, + type ComboboxItem, + type ComboboxOptionItem, + useCombobox, +}; diff --git a/packages/ods-react/src/components/combobox/src/controller/combobox.ts b/packages/ods-react/src/components/combobox/src/controller/combobox.ts new file mode 100644 index 0000000000..79ddf7b9df --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/controller/combobox.ts @@ -0,0 +1,158 @@ +import type { ComboboxCustomOptionRendererArg } from '../context/useCombobox'; +import React, { Children, type ReactElement, type ReactNode, isValidElement } from 'react'; +import { getElementText } from '../../../../utils/element'; +import style from '../components/combobox-option/comboboxOption.module.scss'; +import { type ComboboxGroupItem, type ComboboxItem, type ComboboxOptionItem } from '../context/useCombobox'; + +function createValueToLabelMap(items: ComboboxItem[]): Map { + const map = new Map(); + + items.forEach((item) => { + if (isGroup(item)) { + item.options.forEach((option) => map.set(option.value, option.label)); + } else { + map.set(item.value, item.label); + } + }); + + return map; +} + +function doesOptionMatch( + option: ComboboxOptionItem, + inputValue: string, + customRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element, +): boolean { + if (customRenderer && option.customRendererData) { + const rendered = customRenderer({ + customData: option.customRendererData, + label: option.label, + }); + const text = getElementText(rendered); + return text.toLowerCase().includes(inputValue.toLowerCase()); + } + return option.label.toLowerCase().includes(inputValue.toLowerCase()); +} + +function flattenGroupWithDisabled( + group: ComboboxGroupItem, + inputValue: string, + customRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element, +): (ComboboxOptionItem & { group?: string })[] { + const groupOptions = group.options.map((opt) => ({ + ...opt, + disabled: shouldOptionBeDisabled(opt, inputValue, customRenderer), + group: group.label, + })); + const hasEnabled = groupOptions.some((opt) => !opt.disabled); + return hasEnabled ? groupOptions : []; +} + +function flattenItemsWithDisabled( + items: ComboboxItem[], + inputValue: string, + customRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element, +): (ComboboxOptionItem & { group?: string })[] { + return items.flatMap((item) => { + if (isGroup(item)) { + return flattenGroupWithDisabled(item, inputValue, customRenderer); + } + return [{ + ...item, + disabled: shouldOptionBeDisabled(item, inputValue, customRenderer), + group: '', + }]; + }); +} + +type GetFlatItemsOptions = { + allowNewElement: boolean; + selectedValues: string[]; + customRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element; +}; + +function getFlatItemsWithDisabled( + items: ComboboxItem[], + inputValue: string, + options: GetFlatItemsOptions, +): (ComboboxOptionItem & { group?: string; isNew?: boolean })[] { + const { allowNewElement, selectedValues, customRenderer } = options; + const baseItems = flattenItemsWithDisabled(items, inputValue, customRenderer); + if (shouldAddNewElement(allowNewElement, inputValue, baseItems, selectedValues)) { + return [ + { + disabled: false, + group: '', + isNew: true, + label: inputValue, + value: inputValue, + }, + ...baseItems, + ]; + } + return baseItems; +} + +function highlightInElement(element: ReactNode, search: string): ReactNode { + if (!search) { + return element; + } + const regex = new RegExp(`(${search.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`, 'gi'); + + if (typeof element === 'string' || typeof element === 'number') { + const parts = element.toString().split(regex); + return parts.map((part, i) => + regex.test(part) + ? React.createElement('span', { className: style[ 'combobox-option--highlighted' ], key: i }, part) + : part, + ); + } + if (!isValidElement(element)) { + return element; + } + if (!('props' in element) || !element.props.children) { + return element; + } + const children = (element as ReactElement<{ children: ReactNode }>).props.children; + const highlighted = Children.map(children, (child) => highlightInElement(child, search)); + return React.cloneElement(element as ReactElement, undefined, highlighted); +} + +function isGroup(item: ComboboxItem): item is ComboboxGroupItem { + return 'options' in item; +} + +function shouldAddNewElement( + allowNewElement: boolean, + inputValue: string, + flatItems: (ComboboxOptionItem & { group?: string })[], + selectedValues: string[], +): boolean { + const normalizedInput = inputValue.trim(); + return ( + allowNewElement && + normalizedInput !== '' && + !flatItems.some((opt) => opt.label.toLowerCase() === normalizedInput.toLowerCase()) && + !selectedValues.some((val) => val.trim() === normalizedInput) + ); +} + +function shouldOptionBeDisabled( + option: ComboboxOptionItem, + inputValue: string, + customRenderer?: (arg: ComboboxCustomOptionRendererArg) => JSX.Element, +): boolean { + return !doesOptionMatch(option, inputValue, customRenderer) || !!option.disabled; +} + +export { + createValueToLabelMap, + doesOptionMatch, + flattenGroupWithDisabled, + flattenItemsWithDisabled, + getFlatItemsWithDisabled, + highlightInElement, + isGroup, + shouldAddNewElement, + shouldOptionBeDisabled, +}; diff --git a/packages/ods-react/src/components/combobox/src/dev.module.css b/packages/ods-react/src/components/combobox/src/dev.module.css new file mode 100644 index 0000000000..ede87522de --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/dev.module.css @@ -0,0 +1,23 @@ +.custom-select { + width: 200px; +} + +.custom-select-label { + color: #4d2a00; +} + +.custom-select-control { + background-color: #4bb2f6; +} + +.custom-select-content { + max-height: 100px; +} + +.highlight-custom { + border-radius: 2px; + background: #fff3a3; + padding: 0 2px; + color: #b97a00; + font-weight: bold; +} diff --git a/packages/ods-react/src/components/combobox/src/dev.stories.tsx b/packages/ods-react/src/components/combobox/src/dev.stories.tsx new file mode 100644 index 0000000000..9ebad9cb12 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/dev.stories.tsx @@ -0,0 +1,283 @@ +import { Combobox as ArkCombobox, createListCollection, Portal } from '@ark-ui/react'; +import { useMemo, useState } from 'react'; +import { Combobox, ComboboxContent, ComboboxControl, type ComboboxCustomOptionRendererArg, ComboboxLabel } from '.'; +import { FormField } from '../../form-field/src'; +import style from './dev.module.css'; + +export default { + component: Combobox, + title: 'Combobox dev', +}; + +export const Basic = () => { + const initialItems = [ + { label: 'Dog', value: 'dog' }, + { label: 'Cat', value: 'cat' }, + { label: 'Hamster', value: 'hamster' }, + { label: 'Parrot', value: 'parrot' }, + { label: 'Spider', value: 'spider' }, + { label: 'Goldfish', value: 'goldfish' }, + ]; + + const [items, setItems] = useState(initialItems); + const collection = useMemo(() => + createListCollection({ items }), + [items], + ); + + const handleInputChange = (details: { inputValue: string }) => { + setItems(initialItems.filter(item => + item.label.toLowerCase().includes(details.inputValue.toLowerCase()), + )); + }; + + return ( + + Framework + + + + + + + + + + { collection.items.map((item) => ( + + { item.label } + + + )) } + + + + + + ); +}; + +export const CustomCSS = () => ( + + Label + + + +); + +export const Default = () => ( + + Label + + + +); + +export const Disabled = () => ( + + Label + + + +); + +export const DisabledWhileOpen = () => ( + + Label + + + +); + +export const Empty = () => ( + + Label + + + +); + +export const Groups = () => ( + + Label + + + +); + +export const InFormField = () => ( + + + Label + + + + +); + +export const Placeholder = () => ( + + Label + + + +); + +export const Readonly = () => ( + + Label + + + +); + +export const FlexEnd = () => ( +
+ + Flex end + + + +
+); + +export const Highlight = () => ( + + Label + + + +); + +export const CustomOptions = () => { + const items = [ + { label: 'Apple', value: 'apple', customRendererData: { color: 'red', info: 'Fruit' } }, + { label: 'Bananaspan', value: 'banana', customRendererData: { color: 'yellow', info: 'Fruit' } }, + { label: 'Carrotspan', value: 'carrot', customRendererData: { color: 'orange', info: 'Vegetable' } }, + { label: 'Broccoli', value: 'broccoli', customRendererData: { color: 'green', info: 'Vegetable' } }, + { label: 'Blueberry', value: 'blueberry', customRendererData: { color: 'blue', info: 'Fruit' } }, + ]; + + const customOptionRenderer = ({ label, customData }: ComboboxCustomOptionRendererArg) => { + const data = (customData || {}) as Record; + const color = typeof data.color === 'string' ? data.color : undefined; + const info = typeof data.info === 'string' ? data.info : ''; + return ( + + { label } ({ info }) + + ); + }; + + return ( + + Label + + + + ); +}; diff --git a/packages/ods-react/src/components/combobox/src/index.ts b/packages/ods-react/src/components/combobox/src/index.ts new file mode 100644 index 0000000000..ea39167324 --- /dev/null +++ b/packages/ods-react/src/components/combobox/src/index.ts @@ -0,0 +1,11 @@ +export { Combobox, type ComboboxProp } from './components/combobox/Combobox'; +export { ComboboxContent, type ComboboxContentProp } from './components/combobox-content/ComboboxContent.tsx'; +export { ComboboxControl, type ComboboxControlProp } from './components/combobox-control/ComboboxControl.tsx'; +export { ComboboxLabel, type ComboboxLabelProp } from './components/combobox-label/ComboboxLabel.tsx'; +export { + type ComboboxCustomGroupRendererArg, + type ComboboxGroupItem, + type ComboboxItem, + type ComboboxOptionItem, + type ComboboxCustomOptionRendererArg, +} from './context/useCombobox'; diff --git a/packages/ods-react/src/components/combobox/tests/controller/combobox.spec.ts b/packages/ods-react/src/components/combobox/tests/controller/combobox.spec.ts new file mode 100644 index 0000000000..8479a7ec6d --- /dev/null +++ b/packages/ods-react/src/components/combobox/tests/controller/combobox.spec.ts @@ -0,0 +1,283 @@ +import { type ReactElement, createElement } from 'react'; +import { type ComboboxCustomOptionRendererArg, type ComboboxGroupItem, type ComboboxItem, type ComboboxOptionItem } from '../../src/context/useCombobox'; +import { doesOptionMatch, flattenGroupWithDisabled, flattenItemsWithDisabled, getFlatItemsWithDisabled, highlightInElement, isGroup, shouldAddNewElement, shouldOptionBeDisabled } from '../../src/controller/combobox'; + +describe('Combobox controller', () => { + describe('isGroup', () => { + it('should return true for group items', () => { + const groupItem: ComboboxItem = { + label: 'Group 1', + options: [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + ], + }; + expect(isGroup(groupItem)).toBe(true); + }); + + it('should return false for option items', () => { + const optionItem: ComboboxItem = { + label: 'Option 1', + value: '1', + }; + expect(isGroup(optionItem)).toBe(false); + }); + }); + + describe('doesOptionMatch', () => { + const option: ComboboxOptionItem = { + label: 'Test Option', + value: 'test', + }; + + it('should match when input is part of the label', () => { + expect(doesOptionMatch(option, 'test')).toBe(true); + expect(doesOptionMatch(option, 'Test')).toBe(true); + expect(doesOptionMatch(option, 'option')).toBe(true); + }); + + it('should not match when input is not part of the label', () => { + expect(doesOptionMatch(option, 'xyz')).toBe(false); + }); + + it('should handle custom renderer', () => { + const customOption: ComboboxOptionItem = { + customRendererData: { info: 'Custom Info' }, + label: 'Test Option', + value: 'test', + }; + + const customRenderer = (arg: ComboboxCustomOptionRendererArg): ReactElement => { + return createElement('div', null, `${arg.label} - ${arg.customData?.info || ''}`); + }; + + expect(doesOptionMatch(customOption, 'test', customRenderer)).toBe(true); + expect(doesOptionMatch(customOption, 'custom', customRenderer)).toBe(true); + expect(doesOptionMatch(customOption, 'xyz', customRenderer)).toBe(false); + }); + }); + + describe('shouldAddNewElement', () => { + const flatItems: (ComboboxOptionItem & { group?: string })[] = [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + ]; + + it('should return true when conditions are met', () => { + expect(shouldAddNewElement(true, 'New Option', flatItems, [])).toBe(true); + }); + + it('should return false when allowNewElement is false', () => { + expect(shouldAddNewElement(false, 'New Option', flatItems, [])).toBe(false); + }); + + it('should return false when input is empty', () => { + expect(shouldAddNewElement(true, '', flatItems, [])).toBe(false); + expect(shouldAddNewElement(true, ' ', flatItems, [])).toBe(false); + }); + + it('should return false when input matches existing option', () => { + expect(shouldAddNewElement(true, 'Option 1', flatItems, [])).toBe(false); + }); + + it('should return false when input matches selected value', () => { + expect(shouldAddNewElement(true, 'Selected Option', flatItems, ['Selected Option'])).toBe(false); + }); + }); + + describe('getFlatItemsWithDisabled', () => { + const items: ComboboxItem[] = [ + { + label: 'Group 1', + options: [ + { label: 'Option 1', value: '1' }, + { disabled: true, label: 'Option 2', value: '2' }, + ], + }, + { label: 'Option 3', value: '3' }, + ]; + + it('should flatten items and handle disabled state', () => { + const result = getFlatItemsWithDisabled(items, '', { + allowNewElement: false, + selectedValues: [], + }); + + expect(result).toHaveLength(3); + expect(result[0].label).toBe('Option 1'); + expect(result[1].label).toBe('Option 2'); + expect(result[1].disabled).toBe(true); + expect(result[2].label).toBe('Option 3'); + }); + + it('should filter items based on input value', () => { + const result = getFlatItemsWithDisabled(items, 'Option 1', { + allowNewElement: false, + selectedValues: [], + }); + + expect(result).toHaveLength(3); + expect(result[0].label).toBe('Option 1'); + expect(result[0].disabled).toBe(false); + expect(result[1].label).toBe('Option 2'); + expect(result[1].disabled).toBe(true); + expect(result[2].label).toBe('Option 3'); + expect(result[2].disabled).toBe(true); + }); + + it('should add new element when conditions are met', () => { + const result = getFlatItemsWithDisabled(items, 'New Option', { + allowNewElement: true, + selectedValues: [], + }); + + expect(result).toHaveLength(2); + expect(result[0].label).toBe('New Option'); + expect(result[0].isNew).toBe(true); + expect(result[0].disabled).toBe(false); + expect(result[1].label).toBe('Option 3'); + expect(result[1].disabled).toBe(true); + }); + }); + + describe('shouldOptionBeDisabled', () => { + const option: ComboboxOptionItem = { + label: 'Test Option', + value: 'test', + }; + + it('should return true when option does not match input', () => { + expect(shouldOptionBeDisabled(option, 'xyz')).toBe(true); + }); + + it('should return true when option is explicitly disabled', () => { + const disabledOption = { ...option, disabled: true }; + expect(shouldOptionBeDisabled(disabledOption, 'test')).toBe(true); + }); + + it('should return false when option matches input and is not disabled', () => { + expect(shouldOptionBeDisabled(option, 'test')).toBe(false); + }); + + it('should handle custom renderer', () => { + const customOption: ComboboxOptionItem = { + customRendererData: { info: 'Custom Info' }, + label: 'Test Option', + value: 'test', + }; + + const customRenderer = (arg: ComboboxCustomOptionRendererArg): ReactElement => { + return createElement('div', null, `${arg.label} - ${arg.customData?.info || ''}`); + }; + + expect(shouldOptionBeDisabled(customOption, 'test', customRenderer)).toBe(false); + expect(shouldOptionBeDisabled(customOption, 'xyz', customRenderer)).toBe(true); + }); + }); + + describe('flattenGroupWithDisabled', () => { + const group: ComboboxGroupItem = { + label: 'Group 1', + options: [ + { label: 'Option 1', value: '1' }, + { disabled: true, label: 'Option 2', value: '2' }, + ], + }; + + it('should return all options when group has valid options', () => { + const result = flattenGroupWithDisabled(group, ''); + expect(result).toHaveLength(2); + expect(result[0].disabled).toBe(false); + expect(result[1].disabled).toBe(true); + expect(result[0].group).toBe('Group 1'); + expect(result[1].group).toBe('Group 1'); + }); + + it('should return empty array when all options are disabled', () => { + const allDisabledGroup = { + ...group, + options: group.options.map((opt) => ({ ...opt, disabled: true })), + }; + const result = flattenGroupWithDisabled(allDisabledGroup, ''); + expect(result).toHaveLength(0); + }); + + it('should handle input value filtering', () => { + const result = flattenGroupWithDisabled(group, 'Option 1'); + expect(result).toHaveLength(2); + expect(result[0].disabled).toBe(false); + expect(result[1].disabled).toBe(true); + }); + }); + + describe('flattenItemsWithDisabled', () => { + const items: ComboboxItem[] = [ + { + label: 'Group 1', + options: [ + { label: 'Option 1', value: '1' }, + { disabled: true, label: 'Option 2', value: '2' }, + ], + }, + { label: 'Option 3', value: '3' }, + ]; + + it('should flatten items and handle disabled state', () => { + const result = flattenItemsWithDisabled(items, ''); + expect(result).toHaveLength(3); + expect(result[0].label).toBe('Option 1'); + expect(result[1].label).toBe('Option 2'); + expect(result[1].disabled).toBe(true); + expect(result[2].label).toBe('Option 3'); + }); + + it('should handle input value filtering', () => { + const result = flattenItemsWithDisabled(items, 'Option 1'); + expect(result).toHaveLength(3); + expect(result[0].label).toBe('Option 1'); + expect(result[0].disabled).toBe(false); + expect(result[1].label).toBe('Option 2'); + expect(result[1].disabled).toBe(true); + expect(result[2].label).toBe('Option 3'); + expect(result[2].disabled).toBe(true); + }); + + it('should handle empty items array', () => { + const result = flattenItemsWithDisabled([], ''); + expect(result).toHaveLength(0); + }); + }); + + describe('highlightInElement', () => { + it('should highlight matching text in element', () => { + const element = createElement('div', null, 'Test Option'); + const result = highlightInElement(element, 'test') as ReactElement; + const children = result.props.children; + expect(Array.isArray(children)).toBe(true); + expect(children).toHaveLength(3); + expect(children[0]).toBe(''); + expect(children[1].props.children).toBe('Test'); + expect(children[2]).toBe(' Option'); + }); + + it('should handle case insensitive matching', () => { + const element = createElement('div', null, 'Test Option'); + const result = highlightInElement(element, 'TEST') as ReactElement; + const children = result.props.children; + expect(Array.isArray(children)).toBe(true); + expect(children[1].props.children).toBe('Test'); + }); + + it('should handle no match', () => { + const element = createElement('div', null, 'Test Option'); + const result = highlightInElement(element, 'xyz') as ReactElement; + expect(result.props.children).toStrictEqual(['Test Option']); + }); + + it('should handle empty input', () => { + const element = createElement('div', null, 'Test Option'); + const result = highlightInElement(element, '') as ReactElement; + expect(result.props.children).toBe('Test Option'); + }); + }); +}); diff --git a/packages/ods-react/src/components/combobox/tests/rendering/combobox.e2e.ts b/packages/ods-react/src/components/combobox/tests/rendering/combobox.e2e.ts new file mode 100644 index 0000000000..91d5034c81 --- /dev/null +++ b/packages/ods-react/src/components/combobox/tests/rendering/combobox.e2e.ts @@ -0,0 +1,21 @@ +import 'jest-puppeteer'; +import { gotoStory } from '../../../../helpers/test'; + +describe('Combobox rendering', () => { + it('should render the web component', async() => { + await gotoStory(page, 'rendering/render'); + + expect(await page.waitForSelector('[data-testid="render"]')).not.toBeNull(); + }); + + describe('custom style', () => { + it('should render with custom style applied', async() => { + await gotoStory(page, 'rendering/custom-style'); + + const combobox = await page.waitForSelector('[data-testid="custom-style"]'); + const height = await combobox?.evaluate((el: Element) => el.getBoundingClientRect().height); + + expect(height).toBe(42); + }); + }); +}); diff --git a/packages/ods-react/src/components/combobox/tests/rendering/combobox.stories.tsx b/packages/ods-react/src/components/combobox/tests/rendering/combobox.stories.tsx new file mode 100644 index 0000000000..e92b450c1b --- /dev/null +++ b/packages/ods-react/src/components/combobox/tests/rendering/combobox.stories.tsx @@ -0,0 +1,29 @@ +import { Combobox, ComboboxControl, ComboboxContent } from '../../src'; + +export default { + component: Combobox, + title: 'Tests rendering', +}; + +const items = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, +]; + +export const customStyle = () => ( + + + + +); + +export const render = () => ( + + + + +); diff --git a/packages/ods-react/src/components/combobox/tsconfig.json b/packages/ods-react/src/components/combobox/tsconfig.json new file mode 100644 index 0000000000..e201f26bdd --- /dev/null +++ b/packages/ods-react/src/components/combobox/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["modules.d.ts", "src", "tests"], + "exclude": [".storybook", "node_modules"] +} diff --git a/packages/ods-react/src/components/combobox/typedoc.json b/packages/ods-react/src/components/combobox/typedoc.json new file mode 100644 index 0000000000..1267937419 --- /dev/null +++ b/packages/ods-react/src/components/combobox/typedoc.json @@ -0,0 +1,17 @@ +{ + "disableGit": true, + "disableSources": true, + "entryPoints": ["src/index.ts"], + "excludeExternals": true, + "excludeInternal": true, + "excludePrivate": true, + "excludeProtected": true, + "outputs": [ + { + "name": "json", + "path": "./documentation/combobox.json" + } + ], + "sort": ["source-order"], + "tsconfig":"tsconfig.json" +} diff --git a/packages/ods-react/src/components/index.ts b/packages/ods-react/src/components/index.ts index 1bf257c18b..b544dcc27a 100644 --- a/packages/ods-react/src/components/index.ts +++ b/packages/ods-react/src/components/index.ts @@ -37,3 +37,4 @@ export * from './code/src'; export * from './datepicker/src'; export * from './range/src'; export * from './medium/src'; +export * from './combobox/src'; diff --git a/packages/storybook/stories/components/combobox/combobox.stories.tsx b/packages/storybook/stories/components/combobox/combobox.stories.tsx new file mode 100644 index 0000000000..dbdecd484e --- /dev/null +++ b/packages/storybook/stories/components/combobox/combobox.stories.tsx @@ -0,0 +1,389 @@ +import { type Meta, type StoryObj } from '@storybook/react'; +import React from 'react'; +import { + Combobox, + ComboboxContent, + ComboboxControl, + ComboboxControlProp, + type ComboboxCustomOptionRendererArg, + ComboboxLabel, + type ComboboxProp, +} from '../../../../ods-react/src/components/combobox/src'; +import { CONTROL_CATEGORY } from '../../../src/constants/controls.ts'; +import { excludeFromDemoControls, orderControls } from '../../../src/helpers/controls.ts'; +import { FormField } from '../../../../ods-react/src/components/form-field/src/components/form-field/FormField.tsx'; + +type Story = StoryObj; +type DemoArg = Partial & Partial & { + label?: string, +}; + +const meta: Meta = { + argTypes: excludeFromDemoControls(['customOptionRenderer', 'defaultValue', 'items', 'invalid', 'name', 'onInputValueChange', 'onValueChange', 'required', 'value']), + component: Combobox, + subcomponents: { ComboboxContent, ComboboxControl, ComboboxLabel }, + title: 'ODS Components/Form elements/Combobox', +}; + +export default meta; + +export const Demo: StoryObj = { + render: (arg: DemoArg) => ( + + + { arg.label } + + + + + ), + argTypes: orderControls({ + addNewElementLabel: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: 'text', + }, + allowNewElement: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: 'boolean', + }, + clearable: { + table: { + category: CONTROL_CATEGORY.general, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, + defaultValue: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: 'text', + }, + disabled: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: 'boolean', + }, + highlightResults: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: 'boolean', + }, + invalid: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: 'boolean', + }, + label: { + table: { + category: CONTROL_CATEGORY.slot, + }, + control: 'text', + }, + loading: { + table: { + category: CONTROL_CATEGORY.general, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, + noResultLabel: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: 'text', + }, + placeholder: { + table: { + category: CONTROL_CATEGORY.slot, + }, + control: 'text', + }, + readOnly: { + table: { + category: CONTROL_CATEGORY.general, + }, + control: 'boolean', + }, + }), + args: { + label: 'My combobox', + placeholder: 'Start typing', + }, +}; + +export const Default: Story = { + tags: ['!dev'], + render: ({}) => ( + + + + + ), +}; + +export const Overview: Story = { + tags: ['!dev'], + render: ({}) => ( + + + + + ), +}; + +export const Clearable: Story = { + tags: ['!dev'], + render: ({}) => ( + + + + + ), +}; + +export const Disabled: Story = { + tags: ['!dev'], + render: ({}) => ( + + + + + ), +}; + +export const Readonly: Story = { + tags: ['!dev'], + render: ({}) => ( + + + + + ), +}; + +export const Group: Story = { + tags: ['!dev'], + render: ({}) => ( + + + + + ), +}; + +export const InFormField: Story = { + tags: ['!dev'], + render: ({}) => ( + + + + + + + ), +} + +export const Controlled: Story = { + tags: ['!dev'], + render: () => { + const [value, setValue] = React.useState(['dog']); + return ( + <> + setValue(details.value ?? [])} + > + Controlled combobox + + + +
+ Selected value: {value[0] ?? 'None'} +
+ + ); + }, +}; + +export const Highlight: Story = { + tags: ['!dev'], + render: () => ( + + Label + + + + ), +}; + +export const CustomOptions: Story = { + tags: ['!dev'], + render: () => { + const items = [ + { label: 'Apple', value: 'apple', customRendererData: { color: 'red', info: 'Fruit' } }, + { label: 'Banana', value: 'banana', customRendererData: { color: 'yellow', info: 'Fruit' } }, + { label: 'Carrot', value: 'carrot', customRendererData: { color: 'orange', info: 'Vegetable' } }, + { label: 'Broccoli', value: 'broccoli', customRendererData: { color: 'green', info: 'Vegetable' } }, + { label: 'Blueberry', value: 'blueberry', customRendererData: { color: 'blue', info: 'Fruit' } }, + ]; + const customOptionRenderer = ({ label, customData }: ComboboxCustomOptionRendererArg) => { + const data = (customData || {}) as Record; + const color = typeof data.color === 'string' ? data.color : undefined; + const info = typeof data.info === 'string' ? data.info : ''; + return ( + + {label} ({info}) + + ); + }; + return ( + + Label + + + + ); + }, +}; + +export const Empty: Story = { + tags: ['!dev'], + render: () => ( + + Label + + + + ), +}; + +export const Placeholder: Story = { + tags: ['!dev'], + render: () => ( + + Label + + + + ), +}; diff --git a/packages/storybook/stories/components/combobox/documentation.mdx b/packages/storybook/stories/components/combobox/documentation.mdx new file mode 100644 index 0000000000..1e3ec4ff74 --- /dev/null +++ b/packages/storybook/stories/components/combobox/documentation.mdx @@ -0,0 +1,153 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import * as ComboboxStories from './combobox.stories'; +import { Banner } from '../../../src/components/banner/Banner'; +import { BestPractices } from '../../../src/components/bestPractices/BestPractices'; +import { Heading } from '../../../src/components/heading/Heading'; +import { IdentityCard } from '../../../src/components/identityCard/IdentityCard'; +import { StorybookLink } from '../../../src/components/storybookLink/StorybookLink'; +import { ATOMIC_TYPE } from '../../../src/constants/atomicDesign'; +import { ODS_COMPONENTS_TITLE, STORY } from '../../../src/constants/meta'; + + + + + +_**Combobox** allows users to search, select, and add items from a dynamic or predefined list._ + + + + + + + **Combobox** component allows users to search for and select items from a dynamic list of suggestions or a predefined set of allowed values. It supports both single and multiple selection modes and enables users to create new entries. + + + + +![Component anatomy](components/combobox/anatomy.png "Component anatomy") + +1. Input where the user types the search query. It displays the current input value or selected tags (multiple selection mode) +2. **Dropdown list** displaying a scrollable list of suggested items. Items can be customized using a custom renderer. +3. Tag _(multiple selection mode)_ to display selected items as tags inside the Input field +4. **Clearable** Button _(optional)_ to allow users to clear the input content +5. Spinner _(optional)_ to indicate that data is being fetched +6. **Add entry option** _(optional)_ allows users to create new entries when no matching result is found. The label is customizable +7. **Empty state message** is a customizable message displayed when no suggestion match the query + + + +The **Combobox** is best suited when users need to: +* search within a dataset and dynamically refine results +* provide suggestions based on user input (e.g., domain names, tags, predictive search) +* allow users to add custom values when applicable (e.g., creating tags) + + + + + + + +The dropdown is positioned below the Input field when there is enough space. + +The dropdown width should match the Input field width. + +In multiple selection mode, the Input field height grows dynamically to accommodate selected tags. + + + + + +The dropdown appears when the user clicks on the input field. + + + +Selecting an item triggers a custom event, allowing integrators to process the selected value(s). + + + +Clicking on an item selects it, closes the dropdown, and updates the Input field value. + +If the user exits the field without selecting an item, the input reverts to the placeholder or the last selected value (if any). + + + +User can create new entries when no matching result exists. An **"Add entry"** option appears at the top of the dropdown (label is customizable). + +New entries can be added by clicking on the "Add entry" option. + + + +* search input is case-insensitive (e.g., searching for "a" will match "A") + +* newly created entries are case-sensitive (e.g., adding "cat" will not conflict with an existing "Cat" item) + +Users cannot create an entry that is already selected as a tag. + +If a custom entry added via "Add Entry" option is removed, it does not reappear in the dropdown, as it was not part of the original list. + + + +If the clearable option is enabled, a dedicated Button appears inside the Input field when it contains text: + +* clicking the clearable Button resets the Input field, removing any entered text or selected value(s) +* the dropdown opens after clearing the Input field +* in multiple selection mode, only the current Input text is cleared; selected tags remain + + + +A Spinner can be displayed in the Input field when results are being fetched. + + + +When no matching results are found, a customizable message is displayed in the dropdown. + +This state can be combined with the "Add entry" option. + + + +Items can be categorized into groups in both single and multiple selection modes. + +Group titles cannot be selected, clicked and are excluded from search. + + + + + +The Input field can be focused using the `Tab` key. Pressing `Tab` again moves focus to the next element and closes the dropdown. + +If the Input field is clearable, pressing `Tab` first moves focus to the clear button, then to the next element. + +Pressing `Shift` + `Tab` moves focus to the previous interactive element without confirming any item. + + + +Pressing `Escape` closes the dropdown without selection. + +If the user has typed in the Input field but not made a new selection, pressing `Escape` resets the Input to the previously selected value. Any unsaved input is discarded. + + + +Pressing `Arrow Up/Down` navigates through items in the dropdown. + +Pressing `Enter` selects the hovered item and closes the dropdown. + +Pressing `Backspace` deletes the last character in the Input field (it does not clear the entire field at once). diff --git a/packages/storybook/stories/components/combobox/technical-information.mdx b/packages/storybook/stories/components/combobox/technical-information.mdx new file mode 100644 index 0000000000..ef1f932838 --- /dev/null +++ b/packages/storybook/stories/components/combobox/technical-information.mdx @@ -0,0 +1,58 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import SpecificationsCombobox from '../../../../ods-react/src/components/combobox/documentation/combobox.json'; +import { Banner } from '../../../src/components/banner/Banner'; +import { Heading } from '../../../src/components/heading/Heading'; +import { TechnicalSpecification } from '../../../src/components/technicalSpecification/TechnicalSpecification'; +import * as ComboboxStories from './combobox.stories'; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/yarn.lock b/yarn.lock index 99cbd63a30..8f604a4af4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4025,6 +4025,12 @@ __metadata: languageName: unknown linkType: soft +"@ovhcloud/ods-react-combobox@workspace:packages/ods-react/src/components/combobox": + version: 0.0.0-use.local + resolution: "@ovhcloud/ods-react-combobox@workspace:packages/ods-react/src/components/combobox" + languageName: unknown + linkType: soft + "@ovhcloud/ods-react-datepicker@workspace:packages/ods-react/src/components/datepicker": version: 0.0.0-use.local resolution: "@ovhcloud/ods-react-datepicker@workspace:packages/ods-react/src/components/datepicker"