-
+
+
);
@@ -120,79 +120,65 @@ const InListItemTemplate = (props:Props) => {
const InListItem = Template(InListItemTemplate).bind({});
InListItem.args = {
- comboBoxGroups: withSection,
- label:'InListItem',
- placeholder:'long text overflow effect use case'
+ defaultItems: withoutSection,
+ label: 'InListItem',
+ placeholder: 'long text overflow effect use case',
+ listboxWidth: '20rem',
};
-
InListItem.argTypes = { ...argTypes };
-const MultipleComboBoxTemplate = (props:Props) => {
- return (
- <>
-
-
-
- >
- );
-};
-
-const MultipleComboBox = Template(MultipleComboBoxTemplate).bind({});
-
-MultipleComboBox.args = {
- comboBoxGroups:withSection,
- label:'MultipleComboBox',
-};
-
-MultipleComboBox.argTypes = { ...argTypes };
-
-const DynamicDataTemplate = (props:Props) => {
- const mockDynamicData = [{section:'section1',items:[{key:'key-1',label:'label-1'}]},{section:'section2',items:[{key:'key-2',label:'label-2'}]}];
- const [dynamicData,setDynamicData] = useState(mockDynamicData);
- const [dynamicDataLength,setDynamicDataLength] = useState
(0);
- const [comboBoxGroups,setComboBoxGroups] = useState(dynamicData);
- const [isListOpen,setIsListOpen] = useState(false);
-
- useEffect(()=>{
- const timer = setInterval(()=>{
+const DynamicDataTemplate = (props: Props) => {
+ const mockDynamicData = [
+ { section: 'section1', items: [{ key: 'key-1', label: 'label-1' }] },
+ { section: 'section2', items: [{ key: 'key-2', label: 'label-2' }] },
+ ];
+ const [dynamicData, setDynamicData] = useState(mockDynamicData);
+ const [dynamicDataLength, setDynamicDataLength] = useState(0);
+ const [defaultItems, setDefaultItems] = useState(dynamicData);
+ const [isListOpen, setIsListOpen] = useState(false);
+
+ useEffect(() => {
+ const timer = setInterval(() => {
const randomText = Math.random().toString(36).substr(2, 10);
- mockDynamicData[Math.round(Math.random())].items.push({key:'key-'+randomText,label:'label-'+randomText});
+ mockDynamicData[Math.round(Math.random())].items.push({
+ key: 'key-' + randomText,
+ label: 'label-' + randomText,
+ });
setDynamicData(JSON.parse(JSON.stringify(mockDynamicData)));
- },2000);
- return ()=>{
+ }, 2000);
+ return () => {
clearInterval(timer);
};
- },[]);
+ }, []);
- useEffect(()=>{
+ useEffect(() => {
const length = dynamicData.reduce((total, current) => {
return total + current.items.length;
}, 0);
setDynamicDataLength(length);
- },[dynamicData]);
+ }, [dynamicData]);
- const openStateChange = useCallback((isOpen)=>{
+ const onOpenChange = useCallback((isOpen) => {
setIsListOpen(isOpen);
- },[]);
+ }, []);
- const onInputChange = useCallback(()=>{
- setComboBoxGroups(dynamicData);
- },[dynamicData]);
-
- useEffect(()=>{
- if(!isListOpen){
- setComboBoxGroups(dynamicData);
+ useEffect(() => {
+ if (!isListOpen) {
+ setDefaultItems(dynamicData);
}
- },[isListOpen,dynamicData]);
+ }, [isListOpen, dynamicData]);
return (
<>
- DynamicDataLength:{dynamicDataLength}
- For dynamic data, it is relatively controllable to not re-render when the list is opened, and to re-render when input changes.
-
-
-
+ DynamicDataLength:{dynamicDataLength}
+
+ For dynamic data, it is relatively controllable to not re-render when the list is opened,
+ and to re-render when input changes.
+
+
+
+
>
);
};
@@ -200,10 +186,10 @@ const DynamicDataTemplate = (props:Props) => {
const DynamicData = Template(DynamicDataTemplate).bind({});
DynamicData.args = {
- label:'DynamicData',
+ label: 'DynamicData',
+ listboxWidth: '15rem',
};
DynamicData.argTypes = { ...argTypes };
-
-export { Example, Sections, InListItem, MultipleComboBox, DynamicData };
+export { Example, Sections, DynamicData, InListItem };
diff --git a/src/components/ComboBox/ComboBox.style.scss b/src/components/ComboBox/ComboBox.style.scss
index 9d8db6ab5..d9c811597 100644
--- a/src/components/ComboBox/ComboBox.style.scss
+++ b/src/components/ComboBox/ComboBox.style.scss
@@ -1,140 +1,145 @@
.md-combo-box-wrapper {
+ min-width: 16rem;
position: relative;
- width: var(--local-width);
- height: 2rem;
- min-height: 1.71rem;
+ display: inline-block;
- .md-menu-item-wrapper {
- position: static;
- }
+ --local-width: 100%;
- .md-button-pill-wrapper {
- background-color: var(--mds-color-theme-background-solid-primary-normal);
- border: 1px solid var(--mds-color-theme-outline-input-normal);
- border-radius: 0 0.5rem 0.5rem 0;
- line-height: 2rem;
- width: 2rem;
- height: 2rem;
- }
+ label {
+ display: flex;
+ color: var(--mds-color-theme-text-primary-normal);
+ font-size: 0.875rem;
+ line-height: 1.375rem;
+ margin-left: 0.75rem;
+ margin-bottom: 0.25rem;
+ }
- .md-button-pill-wrapper[data-ghost=true][data-outline=false][data-inverted=false] {
- background-color: var(--mds-color-theme-background-solid-primary-normal);
- }
+ /* to make sure the popover matches the parent width, we have to override tippy css here: */
+ div[data-tippy-root=''] {
+ width: var(--local-width);
+ }
+}
- .md-button-pill-wrapper[data-ghost=true][data-outline=false][data-inverted=false]:hover {
- background-color: var(--mds-color-theme-background-primary-hover);
- }
+.md-combo-box-trigger {
+ background-color: var(--mds-color-theme-background-solid-primary-normal);
+ border: 0.0625rem var(--md-globals-border-style-solid) var(--mds-color-theme-outline-input-normal);
+ border-radius: 0.5rem;
+ position: relative;
+ width: var(--local-width);
+ display: flex;
+ height: auto;
- .md-menu-section-header-wrapper {
- line-height: 2rem;
- }
+ &:focus-within {
+ box-shadow: var(--md-globals-focus-ring-box-shadow);
+ }
- ul.md-menu-wrapper ul[role='group'] {
- margin: 0;
- }
+ &:hover {
+ background-color: var(--mds-color-theme-background-primary-hover);
+ border-color: var(--mds-color-theme-outline-input-normal);
- ul.md-menu-wrapper ul[role='presentation']:not(:first-child)::before {
- display: block;
- width: 100%;
- height: 0.0625rem;
- border-bottom: 0.0625rem solid var(--mds-color-theme-outline-input-normal);
- content: '';
+ input {
+ color: var(--mds-color-theme-text-primary-normal);
}
+ }
- .md-menu-item-wrapper[aria-checked='false'] > div[data-position='fill'] {
- width: calc(100% - 1.75rem);
- }
-
- .md-text-input-container {
- height: 100%;
- border-radius: 0.5rem 0 0 0.5rem;
- border-right: 0px;
- }
+ input:focus {
+ box-shadow: none;
+ }
+}
- .md-text-input-container > input {
- width: 100%;
- padding-right: 0.75rem;
- }
+.md-combo-box-input {
+ width: calc(100% - 2rem);
+ color: var(--mds-color-theme-text-primary-normal);
+ outline: none;
+ height: 2rem;
+ box-sizing: border-box;
+ box-shadow: none;
+ background-color: transparent;
+ border: 0;
+ font-family: var(--md-globals-font-default);
+ font-size: 1rem;
+ padding-left: 0.75rem;
+
+ > span:first-of-type {
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
- .clear-icon {
- display: none;
- width: 0;
- height: 0;
- padding: 0;
- overflow: hidden;
- }
+ &::placeholder {
+ color: var(--mds-color-theme-text-secondary-normal);
+ }
+
+ &::-webkit-contacts-auto-fill-button {
+ background-color: var(--mds-color-theme-text-secondary-normal);
- &[data-input-have-value='false'] {
- .md-text-input-container > input {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ &:hover {
+ background-color: var(--mds-color-theme-text-primary-normal);
}
- }
- &[data-error='true'] {
- .md-text-input-container,
- .md-button-pill-wrapper,
- .md-combo-box-divider {
- border-color: var(--mds-color-theme-text-error-normal);
+ &:active {
+ background-color: var(--mds-color-theme-text-secondary-normal);
}
}
- .md-combo-box-input-section {
- display: flex;
- height: 100%;
+ &:-webkit-autofill,
+ &:-webkit-autofill:hover,
+ &:-webkit-autofill:focus,
+ &:-webkit-autofill:active {
+ -webkit-animation: autofill 0s forwards;
+ animation: autofill 0s forwards;
+ border-radius: 0.438rem 0 0 0.438rem;
}
- .md-combo-box-input {
- width: 100%;
+ @keyframes autofill {
+ 100% {
+ background: transparent;
+ color: inherit;
+ }
}
- .md-combo-box-selection-position{
- width: var(--local-width);
- z-index: 10000;
- position: fixed;
+ @-webkit-keyframes autofill {
+ 100% {
+ background: transparent;
+ color: inherit;
+ }
}
- .md-combo-box-selection-container {
- width: 100%;
- padding: 0.5rem;
- margin-top: 0.25rem;
- overflow: auto;
- background-color: var(--mds-color-theme-background-solid-primary-normal);
- border: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal);
- border-radius: 0.5rem;
- box-sizing: border-box;
+ &:-webkit-autofill-strong-password {
+ margin-top: 0.0625rem;
+ margin-left: 0.0625rem;
+ -webkit-box-shadow: 0 0 0 3.75rem #f9ffbd inset;
+ box-shadow: 0 0 0 3.75rem #f9ffbd inset;
+ border-radius: 0.375rem 0 0 0.375rem;
}
+}
- .md-combo-box-no-result-text {
- color: var(--mds-color-theme-text-primary-normal);
- }
+.md-combo-box-button {
+ border-width: 0 0 0 1px;
+ background: transparent;
+ color: var(--mds-color-theme-text-primary-normal);
+}
- .md-combo-box-button {
- width: var(--local-height);
- height: 100%;
- min-height: 1.71rem;
- position: relative;
-
- .md-combo-box-arrow-icon {
- position: absolute;
- top: 50%;
- right: 50%;
- transform: translate(50%, -50%);
- }
- }
+.md-combo-box-popover {
+ width: 100%;
+ padding: 0.25rem !important;
+ border-radius: 0.75rem !important;
+}
- .md-combo-box-divider {
- width: 2px;
- height: 100%;
- border-top: 1px solid;
- border-bottom: 1px solid;
- border-color: var(--mds-color-theme-outline-input-normal);
- background-color: var(--mds-color-theme-background-solid-primary-normal);
- }
+.md-combo-box-listbox {
+ border: none !important;
+ background: none !important;
+ padding: 0 !important;
+ width: 100%;
- .md-combo-box-input:hover ~.md-combo-box-divider {
- background-color: var(--mds-color-theme-background-primary-hover);
+ li {
+ width: calc(100% - 0.5rem);
+ margin: 0.25rem;
+
+ div[title='no-results'] {
+ color: var(--mds-color-theme-text-primary-normal);
+ }
}
}
diff --git a/src/components/ComboBox/ComboBox.tsx b/src/components/ComboBox/ComboBox.tsx
index 182a9c428..2a6d24a2c 100644
--- a/src/components/ComboBox/ComboBox.tsx
+++ b/src/components/ComboBox/ComboBox.tsx
@@ -1,471 +1,266 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Item } from '@react-stately/collections';
-import TextInput from '../TextInput';
-import Icon from '../Icon';
-import ButtonPill from '../ButtonPill';
-import Menu from '../Menu';
-import {
- KEYS,
- STYLE,
- DEFAULTS,
- ELEMENT,
- EVENT,
-} from './ComboBox.constants';
-import { Props, IComboBoxItem, IComboBoxGroup } from './ComboBox.types';
-
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import React, {
+ ReactElement,
+ useCallback,
+ RefObject,
+ useRef,
+ forwardRef,
+ useState,
+ useEffect,
+} from 'react';
import classnames from 'classnames';
-import './ComboBox.style.scss';
-
-import { handleFilter as handleFilterFunc, searchItem as searchItemFunc, getSumScrollTop as getSumScrollTopFunc } from './ComboBox.utils';
-
-
-const ComboBox: React.FC = (props: Props) => {
+import { useComboBox, useFilter } from 'react-aria';
+import './ComboBox.style.scss';
+import { Props, IComboBoxGroup, IComboBoxItem } from './ComboBox.types';
+import { STYLE, DEFAULTS, EVENT, ICON, KEYS } from './ComboBox.constants';
+import { useComboBoxState } from '@react-stately/combobox';
+import { useKeyboard } from '@react-aria/interactions';
+import Icon from '../Icon';
+import ListBoxBase from '../ListBoxBase';
+import ButtonSimple from '../ButtonSimple';
+import Popover, { PopoverInstance } from '../Popover';
+import Text from '../Text';
+import { filterItems } from './ComboBox.utils';
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+function ComboBox(props: Props, ref: RefObject): ReactElement {
const {
- onArrowButtonPress: onArrowButtonPressCallback,
- onInputChange: onInputChangeCallback,
- onSelectionChange: onSelectionChangeCallback,
- openStateChange: openStateChangeCallBack,
- comboBoxGroups: originComboBoxGroups,
- selectedKey: selectedKeyPayload = DEFAULTS.SELECTEDKEY,
- disabledKeys: disabledKeysPayload = DEFAULTS.DISABLEDKEYS,
- noResultText = DEFAULTS.NO_RESULT_TEXT,
- width = DEFAULTS.WIDTH,
- placeholder = DEFAULTS.PLACEHOLDER,
- shouldFilterOnArrowButton = DEFAULTS.SHOULD_FILTER_ON_ARROW_BUTTON,
- error = DEFAULTS.ERROR,
- inputRef:inputRefProp,
className,
- id,
style,
+ id,
label,
description,
- children,
+ direction,
+ listboxMaxHeight,
+ listboxWidth,
+ noResultLabel,
+ defaultItems,
} = props;
- const componentInputRef = useRef(null);
-
- const menuRef = useRef(null);
- const containerRef = useRef(null);
- const inputRef = inputRefProp || componentInputRef ;
- const selectionPositionRef = useRef(null);
-
- const [isInit, setIsInit] = useState(true);
- const [isOpen, setIsOpen] = useState(false);
- const [isFocused,setIsFocused] = useState(false);
- const [isPreFocused,setIsPreFocused] = useState(false);
- const [shouldFocusItem, setShouldFocusItem] = useState(false);
- const [selectionContainerMaxHeight,setSelectionContainerMaxHeight] = useState();
- const [inputValue, setInputValue] = useState('');
- const [selectedKey, setSelectedKey] = useState(selectedKeyPayload);
- const [groups, setGroups] = useState(originComboBoxGroups);
-
- const isInputFocused = useRef(false);
-
- const wrapperProps = useMemo(()=>({
- className: classnames(className, STYLE.wrapper),
- style: { '--local-width': width, ...style },
- id,
- 'data-input-have-value': !!inputValue,
- 'data-error': error,
- }),[className, width, style, id, inputValue, error]);
-
- const disabledKeys = [KEYS.INPUT_SEARCH_NO_RESULT, ...disabledKeysPayload];
-
-
- // utils
-
- const searchItem: (key: string,groups:IComboBoxGroup[]) => IComboBoxItem | undefined = useCallback(
- (key,groups) => searchItemFunc(key,groups)
- ,[]);
-
- const handleFocusBackToInput = useCallback(
- ()=>{
- inputRef?.current?.focus();
- },[inputRef.current]);
-
- const handleFilter = useCallback(
- (currentInputValue = '') => {
- if(currentInputValue){
- const filterGroup = handleFilterFunc(originComboBoxGroups,currentInputValue);
- setGroups(filterGroup);
- }else{
- setGroups(originComboBoxGroups);
- }
- },[originComboBoxGroups]);
+ const { contains } = useFilter({ sensitivity: 'base' });
+ const { KEY } = EVENT;
- const handleItemFocus = useCallback(
- ()=>{
- const listItems: NodeListOf = containerRef?.current?.querySelectorAll('li[role="menuitemradio"][aria-disabled="false"]');
- if (listItems?.length) {
- const selectedItem = Array.from(listItems).find(item => item?.ariaChecked === 'true');
- if (selectedItem) {
- // prioritize focusing on the selected item.
- selectedItem.focus();
- } else {
- listItems[0].focus();
- }
- }
- setShouldFocusItem(false);
- },[containerRef.current]);
+ const [filteredItems, setFilteredItems] =
+ useState>(defaultItems);
+ const [items, setItems] = useState>(null);
+ const [popoverInstance, setPopoverInstance] = useState();
- // Used to prevent the bottom of the list from exceeding the window boundary.
- const handleSelectionContainerMaxHeight = useCallback(
- ()=>{
- const windowHeight = window.innerHeight;
- const {bottom} = inputRef?.current?.getBoundingClientRect();
-
- const inputDistanceFromViewportBottom = windowHeight - bottom;
- if(inputDistanceFromViewportBottom < ELEMENT.PROPS.SELECTION_CONTAINER_MAX_HEIGHT + 8){
- setSelectionContainerMaxHeight(inputDistanceFromViewportBottom - 8);
- } else {
- setSelectionContainerMaxHeight(ELEMENT.PROPS.SELECTION_CONTAINER_MAX_HEIGHT);
- }
- },[inputRef.current]);
+ const hasBeenOpened = useRef(false);
+ const isFirstOpen = useRef(true);
+ const isOpen = useRef(false);
- // Since the positioning of the list uses ‘fix’, it is necessary to calculate the height of all scroll bars of the list’s ancestor elements,
- // and then set the translate to prevent the positioning of the list from being affected by the scroll bars of the ancestor elements.
- const handleSelectionTranslate = useCallback(
- ()=>{
- if (isOpen && inputRef?.current && selectionPositionRef.current) {
- const scrollTop = getSumScrollTopFunc(inputRef.current);
- selectionPositionRef.current.style.transform = `translateY(${-scrollTop}px)`;
- }
- },[inputRef.current,isOpen]);
+ const inputRef = ref || useRef(null);
+ const buttonRef = useRef(null);
+ const listBoxRef = useRef(null);
+ const popoverRef = useRef(null);
+ const triggerRef = useRef(null);
-
- // event
+ const addNoResults = (disabledKeys: Iterable = []): Iterable => {
+ const keysSet = new Set(disabledKeys);
+ keysSet.add(KEYS.NO_RESULT);
+ return keysSet;
+ };
- const handlerStopPropagation = useCallback(
- (event)=>{
- if(event.code === EVENT.KEY.KEYCODE.ESCAPE){
- event.stopPropagation();
- }
- },[]);
+ const disabledKeys = addNoResults(props.disabledKeys);
- const handleTriggerOutsideForList = useCallback(
- (event) => {
- if(isOpen){
- if (containerRef?.current && !containerRef?.current?.contains(event.target)) {
- setIsOpen(false);
- }
- }
- },[containerRef.current,isOpen]);
-
- const handleTriggerOutsideForInput = useCallback(
- (event) => {
- if (containerRef?.current && !containerRef?.current?.contains(event.target)) {
- setIsOpen(false);
- const currentItem = searchItem(selectedKey,originComboBoxGroups);
- handleFilter(currentItem.label);
- setInputValue(currentItem.label??'');
- }
- },[containerRef.current,selectedKey,originComboBoxGroups,handleFilter,searchItem]);
+ const onInputChange = (value: string) => {
+ if (props.onInputChange) {
+ props.onInputChange(value);
+ }
- const handleItemFocusChange = useCallback(
- (event) => {
- const item = event.target;
- const itemRect = item.getBoundingClientRect();
- const ulRect = menuRef?.current?.getBoundingClientRect();
- if (itemRect.top < ulRect.top || itemRect.bottom > ulRect.bottom) {
- item.scrollIntoView();
- }
- }
- ,[menuRef.current]);
+ isFirstOpen.current = false;
+ const newFilteredItems = filterItems(defaultItems, value || '', contains);
+ if (newFilteredItems.length === 0) {
+ setFilteredItems(noResultLabel ? [{ key: KEYS.NO_RESULT, label: noResultLabel }] : []);
+ } else {
+ setFilteredItems(newFilteredItems);
+ }
+ };
+
+ const state = useComboBoxState({
+ ...props,
+ disabledKeys,
+ defaultItems,
+ items: props.items ?? items,
+ onInputChange,
+ });
+
+ const { buttonProps, inputProps, listBoxProps, labelProps } = useComboBox(
+ {
+ ...props,
+ inputRef,
+ buttonRef,
+ listBoxRef,
+ popoverRef,
+ },
+ state
+ );
- const handlePreventScroll = useCallback(
- (event)=>{
- if(isOpen && selectionPositionRef?.current){
- if(!selectionPositionRef?.current?.contains(event.target)){
- event.preventDefault();
+ const { onKeyDown } = inputProps;
+
+ inputProps.onKeyDown = (e) => {
+ switch (e.key) {
+ case KEY.ARROW_DOWN:
+ case KEY.ARROW_UP:
+ isFirstOpen.current = true;
+ break;
+ case KEY.TAB:
+ if (state.isOpen) {
+ e.preventDefault();
+ e.stopPropagation();
}
- }
- },[selectionPositionRef.current,isOpen]);
-
- const handleInputKeyDown = useCallback(
- (event) => {
- if (event.code === EVENT.KEY.KEYCODE.ESCAPE) {
- if (isOpen) {
- setIsOpen(false);
+ break;
+ case KEY.ESCAPE:
+ if (isOpen.current) {
+ state.close();
} else {
- setInputValue('');
- handleFilter();
+ state.setSelectedKey(null);
}
- }
-
- if (!isOpen && event.code === EVENT.KEY.KEYCODE.ENTER || event.code === EVENT.KEY.KEYCODE.ARROW_DOWN || event.code === EVENT.KEY.KEYCODE.ARROW_UP) {
- setShouldFocusItem(true);
- setIsOpen(true);
- }
- },[isOpen,handleFilter]);
-
- const handleMenuKeyDown = useCallback(
- (event) => {
- if (event.code === EVENT.KEY.KEYCODE.ESCAPE) {
- setIsOpen(false);
- handleFocusBackToInput();
- }else if(event.code === EVENT.KEY.KEYCODE.TAB){
- // when the focus is on the listItem, prevent focus escape
- event.preventDefault();
- }
- },[handleFocusBackToInput]) ;
-
- const handleGetFocusEle = useCallback(
- (event)=>{
- setIsFocused(containerRef?.current?.contains(event.target));
- },[containerRef.current]);
-
- const handleGetPreFocusEle = useCallback(
- (event)=>{
- setIsPreFocused(containerRef?.current?.contains(event.target));
- },[containerRef.current]);
-
- const handleGetInputFocus = useCallback(
- (event)=>{
- isInputFocused.current = (inputRef?.current?.contains(event.target));
- },[inputRef.current]);
-
- const handleInputFocus = useCallback(
- ()=>{
- if(!isOpen){
- handleFilter(inputValue);
- }
- },[handleFilter,inputValue,isOpen]);
-
-
- // effect
-
- useEffect(()=>{
- if(openStateChangeCallBack){
- openStateChangeCallBack(isOpen);
+ break;
+ default:
}
- },[openStateChangeCallBack,isOpen]);
-
- useEffect(()=>{
- if(isInit){
- // isInit is used to solve the case where the input is focused by default during initialization.
- // Since isInputFocused is used in the logic as the basis for whether to setInputValue,
- // special handling is needed if the focus is on the input during initialization.
- if(selectedKey){
- // If ‘selected’ exists, a matching item must be found to complete the initialization.
- const currentItem = searchItem(selectedKey,originComboBoxGroups);
- if(currentItem.label){
- handleFilter(currentItem.label);
- setInputValue(currentItem.label);
- setIsInit(false);
- }
- }else{
- setIsInit(false);
+ onKeyDown(e);
+ };
+
+ useEffect(() => {
+ isFirstOpen.current = !state.isOpen;
+ isOpen.current = state.isOpen;
+ }, [state.isOpen]);
+
+ useEffect(() => {
+ // display all items when listBox first open
+ const items = !isFirstOpen.current ? filteredItems : defaultItems;
+ setItems(items);
+ }, [filteredItems, defaultItems, state.inputValue]);
+
+ const getArrowIcon = (isOpen: boolean) => (isOpen ? ICON.ARROW_UP : ICON.ARROW_DOWN);
+
+ const handleFocusBackOnTrigger = useCallback(() => {
+ inputRef.current?.focus();
+ }, [inputRef]);
+
+ useEffect(() => {
+ if (popoverInstance) {
+ if (!state.isOpen) {
+ setItems(defaultItems);
}
- }else{
- if(isInputFocused.current){
- // If the focus is on the input, it indicates that the user may be operating the input,
- // so here we do not want the inputValue to change spontaneously.
- handleFilter(inputValue);
- }else {
- const currentItem = searchItem(selectedKey,originComboBoxGroups);
- handleFilter(currentItem.label);
- setInputValue(currentItem.label??'');
- }
- }
- },[originComboBoxGroups,selectedKey,inputValue,isInit,handleFilter,searchItem]);
-
- useEffect(()=>{
- handleSelectionTranslate();
- handleSelectionContainerMaxHeight();
- },[handleSelectionTranslate,handleSelectionContainerMaxHeight]);
-
- useEffect(()=>{
- document.addEventListener('focusin',handleGetFocusEle);
- containerRef?.current?.addEventListener('focusout', handleGetPreFocusEle);
- return()=>{
- document.removeEventListener('focusin',handleGetFocusEle);
- containerRef?.current?.removeEventListener('focusout', handleGetPreFocusEle);
- };
- },[containerRef.current,handleGetFocusEle,handleGetPreFocusEle]);
-
- useEffect(()=>{
- // Fix the issue where focusing on the input in certain situations does not correctly filter the expanded items
- inputRef?.current?.addEventListener('focus',handleInputFocus);
- return()=>{
- inputRef?.current?.removeEventListener('focus',handleInputFocus);
- };
- },[inputRef.current,handleInputFocus]);
-
- useEffect(()=>{
- document.addEventListener('mousedown', handleTriggerOutsideForList);
- return()=>{
- document.removeEventListener('mousedown', handleTriggerOutsideForList);
- };
- },[handleTriggerOutsideForList]);
-
- useEffect(()=>{
- document.addEventListener('mousedown', handleTriggerOutsideForInput);
- return()=>{
- document.removeEventListener('mousedown', handleTriggerOutsideForInput);
- };
- },[handleTriggerOutsideForInput]);
-
- useEffect(()=>{
- window.addEventListener('mousewheel',handlePreventScroll,{ passive: false });
- return()=>{
- window.removeEventListener('mousewheel',handlePreventScroll);
- };
- },[handlePreventScroll]);
-
- useEffect(()=>{
- containerRef?.current?.addEventListener('keydown',handlerStopPropagation);
- return()=>{
- containerRef?.current?.removeEventListener('keydown',handlerStopPropagation);
- };
- },[containerRef.current,handlerStopPropagation]);
-
- useEffect(()=>{
- document.addEventListener('focusin',handleGetInputFocus);
- return()=>{
- document.removeEventListener('focusin',handleGetInputFocus);
- };
- },[handleGetInputFocus]);
-
- useEffect(()=>{
- menuRef?.current?.addEventListener('focusin', handleItemFocusChange);
-
- return()=>{
- menuRef?.current?.removeEventListener('focusin', handleItemFocusChange);
- };
- },[menuRef.current,handleItemFocusChange]);
-
- useEffect(()=>{
- menuRef?.current?.addEventListener('keydown', handleMenuKeyDown);
-
- return()=>{
- menuRef?.current?.removeEventListener('keydown', handleMenuKeyDown);
- };
- },[menuRef.current,handleMenuKeyDown]);
-
- useEffect(()=>{
- inputRef?.current?.addEventListener('keydown', handleInputKeyDown);
- return()=>{
- inputRef?.current?.removeEventListener('keydown', handleInputKeyDown);
- };
- }, [inputRef?.current,handleInputKeyDown]);
-
- useEffect(()=>{
- if(shouldFocusItem && isOpen) {
- handleItemFocus();
}
- },[shouldFocusItem,isOpen,handleItemFocus]);
-
- // Used to handle the logic of inputValue when the focus switches from inside the component to outside.
- useEffect(()=>{
- if(containerRef?.current){
- if(!isFocused && isPreFocused){
- if(selectedKey){
- const currentItem = searchItem(selectedKey,originComboBoxGroups);
- setInputValue(currentItem.label);
- handleFilter(currentItem.label);
- }else{
- setInputValue('');
- handleFilter();
+ }, [state.isOpen, popoverInstance, items]);
+
+ useEffect(() => {
+ if (popoverInstance) {
+ if (state.isOpen) {
+ // show popover once state changes to isOpen = true
+ popoverInstance.show();
+ hasBeenOpened.current = true;
+ } else {
+ // hide popover once state changes to isOpen = false
+ popoverInstance.hide();
+ if (hasBeenOpened.current) {
+ // only do this if it has been opened previously to prevent unexpected focus
+ handleFocusBackOnTrigger();
}
- setIsOpen(false);
}
}
- },[containerRef.current,isPreFocused,isFocused,selectedKey,originComboBoxGroups,searchItem,handleFilter]);
-
-
- // subcomponent event
-
- const onInputChange = useCallback(
- (event)=>{
- const currentInputValue = event.target.value;
- if(!isOpen){
- setIsOpen(true);
- }
- setInputValue(currentInputValue);
- handleFilter(currentInputValue);
- if(onInputChangeCallback){
- onInputChangeCallback(event);
- }
- },[isOpen,onInputChangeCallback,handleFilter]);
+ }, [state.isOpen, popoverInstance]);
+
+ /**
+ * Handle closeOnComboBox from @react-aria manually
+ */
+ const closePopover = useCallback(() => {
+ state.close();
+ }, []);
+
+ delete buttonProps['aria-label'];
+ delete listBoxProps['aria-label'];
+
+ if (props['aria-required']) {
+ inputProps['aria-required'] = inputProps.value ? 'false' : 'true';
+ }
+
+ const triggerComponent = (
+
+
+
+
+
+
+
+
+ );
- const onAction = useCallback(
- (key: string) => {
- setSelectedKey(key);
- const currentItem = searchItem(key,originComboBoxGroups);
- setInputValue(currentItem.label);
- handleFilter(currentItem.label);
- if(onSelectionChangeCallback){
- onSelectionChangeCallback(currentItem);
+ const { keyboardProps } = useKeyboard({
+ onKeyDown: (event) => {
+ if (event.key === KEY.ESCAPE) {
+ closePopover();
}
- setIsOpen(false);
- handleFocusBackToInput();
- },[originComboBoxGroups,handleFocusBackToInput,onSelectionChangeCallback,searchItem,handleFilter]);
+ },
+ });
+ // delete color prop which is passed down and used in the ModalContainer
+ // because it conflicts with the HTML color property
+ return (
+
+ {label && (
+ // eslint-disable-next-line jsx-a11y/label-has-for
+
+ )}
+
, 'color'>)}
+ style={{
+ maxHeight: listboxMaxHeight || 'none',
+ display: state.isOpen ? undefined : 'none',
+ }}
+ strategy={listboxWidth ? 'fixed' : 'absolute'}
+ >
+
+
+ {description &&
{description}
}
+
+ );
+}
- const onArrowButtonPress = useCallback(
- (event)=>{
- if(!isOpen){
- setShouldFocusItem(true);
- if(!shouldFilterOnArrowButton){
- handleFilter();
- }else{
- handleFilter(inputValue);
- }
- }
- if(onArrowButtonPressCallback){
- onArrowButtonPressCallback(event);
- }
- setIsOpen(!isOpen);
- },[isOpen,inputValue,shouldFilterOnArrowButton,handleFilter,onArrowButtonPressCallback]);
+/**
+ * Dropdown / ComboBox Element which displays a listbox with options.
+ */
+const _ComboBox = forwardRef(ComboBox);
- return (
- <>
- {label ? ({label}
) : null}
-
-
- {isOpen ? (
-
-
-
-
-
- ) : null}
-
- {description && ({description}
)}
- >
- );
-};
+_ComboBox.displayName = 'ComboBox';
-export default ComboBox;
+export default _ComboBox as (props: Props & { ref?: RefObject }) => ReactElement;
diff --git a/src/components/ComboBox/ComboBox.types.ts b/src/components/ComboBox/ComboBox.types.ts
index bf6cd533c..aa25fd8ec 100644
--- a/src/components/ComboBox/ComboBox.types.ts
+++ b/src/components/ComboBox/ComboBox.types.ts
@@ -1,7 +1,6 @@
+import type { ComboBoxProps } from '@react-types/combobox';
import { CollectionChildren } from '@react-types/shared';
-import { PressEvent } from '@react-types/shared/src/events';
-import { CSSProperties, RefObject } from 'react';
-
+import { CSSProperties } from 'react';
export type IComboBoxItem = {
key: string;
@@ -14,84 +13,68 @@ export type IComboBoxGroup = {
section?: string;
};
-export interface Props {
- /**
- * Handler that is called when an item is selected in the list.
- * If the selected item matches the selectedKey, the parameter is undefined.
- */
- onSelectionChange?: (item: IComboBoxItem) => void;
- /**
- * Handler that is called when the ComboBox input value changes.
- */
- onInputChange?: (event: InputEvent) => void;
- /**
- * Handler that is called when the arrowButton pressed.
- */
- onArrowButtonPress?: (event: PressEvent) => void;
- /**
- * Handler that is called when isOpen state of list chanages.
- */
- openStateChange?: (isOpen: boolean) => void;
+export type Direction = 'top' | 'bottom';
+
+export interface Props extends ComboBoxProps {
/**
* id: id of help message.
*/
id?: string;
+
/**
* Custom style for overriding this component's CSS.
*/
style?: CSSProperties;
+
/**
* Custom class to be able to override the component's CSS.
*/
className?: string;
+
/**
* Label/message to be displayed with this component.
*/
label?: string;
+
/**
* Description associated with this component. Appears below the title.
*/
description?: string;
- /**
- * Description associated with this component. Appears below the title.
- */
- error?: boolean;
- /**
- * Override the list box width and ComboBox container width.
- * defaultValue: 16.25rem
- */
- width?: string;
- /**
- * Text to display inside the input when there is no inputValue.
- */
- placeholder?: string;
- /**
- * This property represents whether to filter based on the input value when click the arrowButton.
- */
- shouldFilterOnArrowButton?: boolean;
+
/**
* Text to display inside the list box when there are no items that match the user's input.
- * defaultValue: No results found
*/
- noResultText?: string;
+ noResultLabel?: string;
+
/**
- * The list of options for this component.
+ * Direction in which the option list will display
+ * @default bottom
*/
- comboBoxGroups: IComboBoxGroup[];
+ direction?: Direction;
+
/**
- * The currently disabled keys in the collection.
+ * To override the list box max height
*/
- disabledKeys?: string[];
+ listboxMaxHeight?: string;
+
/**
- * The currently selected key in the collection.
+ * Override the list box width to allow for fixed popover strategy
+ *
+ * To style the list box without applying fixed popover strategy, pass in className instead
+ *
+ * NOTE: if set, the popover strategy will be set to 'fixed'
*/
- selectedKey?: string;
+ listboxWidth?: string;
+
/**
- * Used to get the input DOM within the component.
+ * The list of ComboBox items (uncontrolled).
*/
- inputRef?: RefObject;
+ items?: Array;
+
/**
- * Child components of this component.
+ * The list of ComboBox items (controlled).
*/
+ defaultItems?: Array;
+
children: CollectionChildren;
}
diff --git a/src/components/ComboBox/ComboBox.unit.test.tsx b/src/components/ComboBox/ComboBox.unit.test.tsx
index d8a78eece..9e15531f9 100644
--- a/src/components/ComboBox/ComboBox.unit.test.tsx
+++ b/src/components/ComboBox/ComboBox.unit.test.tsx
@@ -1,14 +1,13 @@
import ComboBox from '.';
-import React, { createRef } from 'react';
+import React from 'react';
import { Item, Section } from '@react-stately/collections';
import { STYLE } from './ComboBox.constants';
import { mountAndWait } from '../../../test/utils';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '@testing-library/react';
-import { IComboBoxGroup } from './ComboBox.types';
+import { IComboBoxGroup, IComboBoxItem } from './ComboBox.types';
import { act } from 'react-dom/test-utils';
-import TextInput from '../TextInput';
jest.mock('@react-aria/utils');
jest.mock('uuid', () => {
return {
@@ -19,17 +18,13 @@ jest.mock('uuid', () => {
describe('ComboBox', () => {
let container;
- const withoutSection: IComboBoxGroup[] = [
- {
- items: [
- { key: 'key1', label: 'item1' },
- { key: 'key2', label: 'item2' },
- { key: 'key3', label: 'item3' },
- { key: 'key4', label: 'menu1' },
- { key: 'key5', label: 'menu2' },
- { key: 'key6', label: 'menu3' },
- ],
- },
+ const withoutSection: IComboBoxItem[] = [
+ { key: 'key1', label: 'item1' },
+ { key: 'key2', label: 'item2' },
+ { key: 'key3', label: 'item3' },
+ { key: 'key4', label: 'listbox1' },
+ { key: 'key5', label: 'listbox2' },
+ { key: 'key6', label: 'listbox3' },
];
const withSection: IComboBoxGroup[] = [
@@ -54,27 +49,37 @@ describe('ComboBox', () => {
];
describe('snapshot', () => {
- const renderChildren = (group) => {
- const itemsEle: any = group?.items?.map((menuItem) => {
- return menuItem.popoverText ? (
- -
-
{menuItem.popoverText}
+ const renderChildren = (itemOrGroup) => {
+ if ('items' in itemOrGroup) {
+ const group = itemOrGroup;
+ const itemsEle = group.items.map((listboxItem: IComboBoxItem) => (
+ -
+
{listboxItem.label}
+ ));
+
+ return group.section ? (
+
) : (
- -
-
{menuItem.label}
+
+ );
+ } else {
+ const item = itemOrGroup;
+ return (
+ -
+ {item.label}
);
- });
-
- return ;
+ }
};
it('should match snapshot label', async () => {
expect.assertions(1);
container = await mountAndWait(
-
+
{renderChildren}
);
@@ -86,16 +91,16 @@ describe('ComboBox', () => {
expect.assertions(1);
container = await mountAndWait(
-
+
{(group) => {
- const itemsEle = group?.items?.map((menuItem) => {
- return menuItem.popoverText ? (
- -
-
{menuItem.popoverText}
+ const itemsEle = group?.items?.map((listboxItem) => {
+ return listboxItem.popoverText ? (
+ -
+
{listboxItem.popoverText}
) : (
- -
-
{menuItem.label}
+ -
+
{listboxItem.label}
);
});
@@ -118,7 +123,7 @@ describe('ComboBox', () => {
const className = 'example-class';
container = await mountAndWait(
-
+
{renderChildren}
);
@@ -132,7 +137,7 @@ describe('ComboBox', () => {
const style = { color: 'pink' };
container = await mountAndWait(
-
+
{renderChildren}
);
@@ -146,7 +151,7 @@ describe('ComboBox', () => {
const placeholder = 'ComboBox';
container = await mountAndWait(
-
+
{renderChildren}
);
@@ -154,13 +159,13 @@ describe('ComboBox', () => {
expect(container).toMatchSnapshot();
});
- it('should match snapshot with noResultText', async () => {
+ it('should match snapshot with noResultLabel', async () => {
expect.assertions(1);
- const noResultText = 'No result';
+ const noResultLabel = 'No result';
container = await mountAndWait(
-
+
{renderChildren}
);
@@ -168,14 +173,13 @@ describe('ComboBox', () => {
expect(container).toMatchSnapshot();
});
-
- it('should match snapshot with width', async () => {
+ it('should match snapshot with listboxWidth', async () => {
expect.assertions(1);
- const width = '16rem';
+ const listboxWidth = '16rem';
container = await mountAndWait(
-
+
{renderChildren}
);
@@ -186,7 +190,7 @@ describe('ComboBox', () => {
describe('attributes', () => {
it('should have its wrapper class', async () => {
container = await mountAndWait(
- {renderChildren}
+ {renderChildren}
);
const element = container.find(ComboBox).getDOMNode();
@@ -198,7 +202,7 @@ describe('ComboBox', () => {
const className = 'example-class';
container = await mountAndWait(
-
+
{renderChildren}
);
@@ -215,25 +219,23 @@ describe('ComboBox', () => {
const styleString = 'color: pink;';
const wrapper = await mountAndWait(
-
+
{renderChildren}
);
const element = wrapper.find(ComboBox).getDOMNode();
- expect(element.getAttribute('style')).toBe(
- `--local-width: 16.25rem; ${styleString}`
- );
+ expect(element.getAttribute('style')).toBe(`--local-width: 100%; ${styleString}`);
});
- it('should have provided style when style is width', async () => {
+ it('should have provided style when style is listboxWidth', async () => {
expect.assertions(1);
- const width = '16rem';
+ const listboxWidth = '16rem';
const styleString = '--local-width: 16rem;';
const wrapper = await mountAndWait(
-
+
{renderChildren}
);
@@ -242,76 +244,20 @@ describe('ComboBox', () => {
expect(element.getAttribute('style')).toBe(`${styleString}`);
});
- it('should have provided label when label is provided', async () => {
- expect.assertions(1);
-
- const label = 'ComboBox';
-
- const wrapper = await mountAndWait(
-
- {renderChildren}
-
- );
-
- const labelContainer = wrapper.find('div').filter({ className: 'md-combo-box-label' });
-
- expect(labelContainer.props()).toEqual({
- className: 'md-combo-box-label',
- children: label,
- });
- });
-
- it('should have provided description when description is provided', async () => {
- expect.assertions(1);
+ // it('should have provided error style when error is provided', async () => {
+ // expect.assertions(1);
- const description = 'ComboBox description';
+ // const error = true;
- const wrapper = await mountAndWait(
-
- {renderChildren}
-
- );
+ // const wrapper = await mountAndWait(
+ //
+ // {renderChildren}
+ //
+ // );
+ // const element = wrapper.find(ComboBox).getDOMNode();
- const descriptionContainer = wrapper
- .find('div')
- .filter({ className: 'md-combo-box-description' });
-
- expect(descriptionContainer.props()).toEqual({
- className: 'md-combo-box-description',
- children: description,
- });
- });
-
- it('should have provided error style when error is provided', async () => {
- expect.assertions(1);
-
- const error = true;
-
- const wrapper = await mountAndWait(
-
- {renderChildren}
-
- );
- const element = wrapper.find(ComboBox).getDOMNode();
-
- expect(element.getAttribute('data-error')).toBe(`${error}`);
- });
-
- it('should have expected props on selectedKey', async () => {
- expect.assertions(1);
-
- const selectedKey = 'key1';
-
- const wrapper = await mountAndWait(
-
- {renderChildren}
-
- );
-
- expect(
- wrapper.find('[aria-label="md-combo-box-input"]').at(0).props()
- ).toHaveProperty('value', 'item1');
- });
+ // expect(element.getAttribute('data-error')).toBe(`${error}`);
+ // });
it('should have expected props on placeholder', async () => {
expect.assertions(1);
@@ -319,71 +265,40 @@ describe('ComboBox', () => {
const placeholder = 'please select';
const wrapper = await mountAndWait(
-
+
{renderChildren}
);
- expect(
- wrapper.find('[aria-label="md-combo-box-input"]').at(0).props()
- ).toHaveProperty('placeholder', placeholder);
- });
-
- it('should match inputRef props', async () => {
- expect.assertions(1);
-
- const inputRef = createRef();
-
- const wrapper = await mountAndWait(
-
- {renderChildren}
-
+ expect(wrapper.find('.md-combo-box-input').at(0).props()).toHaveProperty(
+ 'placeholder',
+ placeholder
);
-
- expect(
- wrapper.find(TextInput).props()['aria-label']
- ).toEqual(inputRef.current.getAttribute('aria-label'));
});
describe('actions', () => {
- it('should show menu on click', async () => {
+ it('should show list on click', async () => {
const user = userEvent.setup();
- render({renderChildren});
+ render({renderChildren});
- const menuItem = screen.queryByRole('menu');
- expect(menuItem).not.toBeInTheDocument();
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
await user.click(screen.getByRole('button'));
- expect(screen.getByRole('menu')).toBeVisible();
- });
-
- it('should call onArrowButtonPress when click', async () => {
- const user = userEvent.setup();
- const onArrowButtonPress = jest.fn();
-
- render(
-
- {renderChildren}
-
- );
-
- await user.click(screen.getByRole('button'));
-
- expect(onArrowButtonPress).toHaveBeenCalled();
+ expect(screen.getByRole('listbox')).toBeVisible();
});
it('should call onInputChange when type on input', async () => {
const onInputChange = jest.fn();
render(
-
+
{renderChildren}
);
- await userEvent.type(screen.getByLabelText('md-combo-box-input'), 'hello');
+ await userEvent.type(screen.getByRole('combobox'), 'hello');
expect(onInputChange).toHaveBeenCalled();
});
@@ -393,62 +308,33 @@ describe('ComboBox', () => {
const onSelectionChange = jest.fn();
render(
-
+
{renderChildren}
);
const button = screen
- .queryAllByRole('button')
- .find((button) => button.classList.contains('md-combo-box-button'));
+ .queryAllByRole('button')
+ .find((button) => button.classList.contains('md-combo-box-button'));
- act(()=>{
+ act(() => {
button.focus();
});
await user.keyboard('{Enter}');
- expect(screen.getByRole('menu')).toBeVisible();
-
- const item = screen.getByText('item1').parentElement;
- await waitFor(() => {
- expect(item.parentElement).toHaveFocus();
- });
+ expect(screen.getByRole('listbox')).toBeVisible();
+
expect(onSelectionChange).not.toHaveBeenCalled();
- await user.keyboard('{Enter}');
+ await user.click(screen.getAllByRole('option')[0]);
expect(onSelectionChange).toHaveBeenCalled();
});
- it('should call openStateChange when list is expanded or collapsed', async () => {
- const user = userEvent.setup();
- const openStateChange = jest.fn();
-
- render(
-
- {renderChildren}
-
- );
-
- const button = screen
- .queryAllByRole('button')
- .find((button) => button.classList.contains('md-combo-box-button'));
-
- act(()=>{
- button.focus();
- });;
- expect(openStateChange).toBeCalledWith(false);
- await user.keyboard('{Enter}');
- expect(screen.getByRole('menu')).toBeVisible();
- expect(openStateChange).toBeCalledWith(true);
- await user.keyboard('{Escape}');
- expect(openStateChange).toBeCalledWith(false);
- });
-
it('should show disabledKeys when click', async () => {
const user = userEvent.setup();
const disabledKeys = ['key1'];
const { container } = render(
-
+
{renderChildren}
);
@@ -461,30 +347,30 @@ describe('ComboBox', () => {
);
});
- it('should show menu when focused and pressing enter', async () => {
+ it('should show list when focused and pressing enter', async () => {
const user = userEvent.setup();
- render({renderChildren});
+ render({renderChildren});
- const menuItem = screen.queryByRole('menu');
- expect(menuItem).not.toBeInTheDocument();
+ const list = screen.queryByRole('listbox');
+ expect(list).not.toBeInTheDocument();
const button = screen.getByRole('button');
- act(()=>{
+ act(() => {
button.focus();
- });;
+ });
expect(button).toHaveFocus();
await user.keyboard('{Enter}');
- expect(screen.getByRole('menu')).toBeVisible();
+ expect(screen.getByRole('listbox')).toBeVisible();
});
- it('should hide menu when clicking outside', async () => {
+ it('should hide list when clicking outside', async () => {
const user = userEvent.setup();
render(
<>
- {renderChildren}
+ {renderChildren}
>
);
@@ -494,20 +380,20 @@ describe('ComboBox', () => {
.find((button) => button.classList.contains('md-combo-box-button'));
await user.click(ele);
- expect(screen.getByRole('menu')).toBeVisible();
+ expect(screen.getByRole('listbox')).toBeVisible();
- await user.click(screen.getByRole('button', { name: 'button-outside' }));
+ await user.click(screen.getAllByRole('button')[1]);
await waitFor(() => {
- expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
});
- it('should have show noResultText when items is empty', async () => {
- const noResultText = 'empty result';
+ it('should have show noResultLabel when items is empty', async () => {
+ const noResultLabel = 'empty result';
render(
-
+
{renderChildren}
);
@@ -515,34 +401,9 @@ describe('ComboBox', () => {
const user = userEvent.setup();
await user.click(screen.getByRole('button'));
- await userEvent.type(screen.getByLabelText('md-combo-box-input'), 'hello');
-
- expect(screen.getByLabelText('md-combo-box-no-result-text')).toHaveTextContent(
- noResultText
- );
- });
-
- it('if shouldFilterOnArrowButton is false, should not filter when press arrowButton', async () => {
-
- render(
-
- {renderChildren}
-
- );
-
- const user = userEvent.setup();
- const input = screen.getByLabelText('md-combo-box-input');
- const button = screen.getByRole('button');
- act(()=>{
- input.focus();
- });
- await user.keyboard('{4}');
- await user.keyboard('{Escape}');
- await user.click(button);
- expect(screen.getByRole('menu')).toBeVisible();
+ await userEvent.type(screen.getByRole('combobox'), 'hello');
- const item = screen.getByText('item1').parentElement;
- expect(item).toBeVisible();
+ expect(screen.getByRole('option')).toHaveTextContent(noResultLabel);
});
it('When list is hidden, entering text in the input, list will display', async () => {
@@ -550,85 +411,63 @@ describe('ComboBox', () => {
render(
<>
- {renderChildren}
+ {renderChildren}
>
);
- const input = screen.getByLabelText('md-combo-box-input');
- act(()=>{
+ const input = screen.getByRole('combobox');
+ act(() => {
input.focus();
});
- await user.keyboard('{Enter}');
- expect(screen.getByRole('menu')).toBeVisible();
+ await user.keyboard('{i}');
+ expect(screen.getByRole('listbox')).toBeVisible();
await waitFor(() => {
- expect(screen.queryByRole('menu')).toBeInTheDocument();
+ expect(screen.queryByRole('listbox')).toBeInTheDocument();
});
});
- it('when input is focused, list is hidden, press Enter, list will display, and item will be focused', async () => {
+ it('when input is focused, list is hidden, press arrowDown, list will display, and item will be focused', async () => {
const user = userEvent.setup();
render(
<>
- {renderChildren}
+
+ {renderChildren}
+
>
);
- const input = screen.getByLabelText('md-combo-box-input');
- act(()=>{
+ const input = screen.getByRole('combobox');
+ act(() => {
input.focus();
});
- await user.keyboard('{Enter}');
- expect(screen.getByRole('menu')).toBeVisible();
+ await user.keyboard('{arrowDown}');
+ expect(screen.getByRole('listbox')).toBeVisible();
- const item = screen.getByText('item2').parentElement;
- await waitFor(() => {
- expect(item.parentElement).toHaveFocus();
- });
+ const item = screen.getAllByRole('option')[1];
+ expect(item.getAttribute('data-focused')).toBe('true');
});
- it('when input is focused, list is hidden, press ArrowDown, list will display, and item will be focused', async () => {
- const user = userEvent.setup();
-
- render(
- <>
- {renderChildren}
- >
- );
-
- const input = screen.getByLabelText('md-combo-box-input');
- act(()=>{
- input.focus();
- });
- await user.keyboard('{ArrowDown}');
- expect(screen.getByRole('menu')).toBeVisible();
-
- const item = screen.getByText('item1').parentElement;
-
- await waitFor(() => {
- expect(item.parentElement).toHaveFocus();
- });
- });
it('when input is focused, list is displayed, press Escape, list will be hidden', async () => {
const user = userEvent.setup();
render(
<>
- {renderChildren}
+ {renderChildren}
>
);
- const input = screen.getByLabelText('md-combo-box-input');
- act(()=>{
+ const input = screen.getByRole('combobox');
+ act(() => {
input.focus();
});
await user.keyboard('{i}');
- expect(screen.getByRole('menu')).toBeVisible();
+ expect(screen.getByRole('listbox')).toBeVisible();
await user.keyboard('{Escape}');
await waitFor(() => {
- expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
@@ -637,18 +476,19 @@ describe('ComboBox', () => {
render(
<>
- {renderChildren}
+
+ {renderChildren}
+
>
);
- const input = screen.getByLabelText('md-combo-box-input');
- expect(input).toHaveProperty('value','item1');
- act(()=>{
+ const input = screen.getByRole('combobox');
+ expect(input).toHaveProperty('value', 'item1');
+ act(() => {
input.focus();
});
await user.keyboard('{Escape}');
-
- expect(input).toHaveProperty('value','');
+ expect(input).toHaveProperty('value', '');
});
it('when listitem is focused, press Escape, input will be focused', async () => {
@@ -656,20 +496,18 @@ describe('ComboBox', () => {
render(
<>
- {renderChildren}
+ {renderChildren}
>
);
- const input = screen.getByLabelText('md-combo-box-input');
- act(()=>{
+ const input = screen.getByRole('combobox');
+ act(() => {
input.focus();
});
- await user.keyboard('{Enter}');
- expect(screen.getByRole('menu')).toBeVisible();
+ await user.keyboard('{ArrowDown}');
+ expect(screen.getByRole('listbox')).toBeVisible();
- const item = screen.getByText('item1').parentElement;
- await waitFor(() => {
- expect(item.parentElement).toHaveFocus();
- });
+ const item = screen.getAllByRole('option')[0];
+ expect(item.getAttribute('data-focused')).toBe('true');
await user.keyboard('{Escape}');
await waitFor(() => {
@@ -677,119 +515,99 @@ describe('ComboBox', () => {
});
});
- it('when listitem is focused, press Tab, The focus will not escape.', async () => {
- const user = userEvent.setup();
-
- render(
- <>
- {renderChildren}
- >
- );
- const input = screen.getByLabelText('md-combo-box-input');
- act(()=>{
- input.focus();
- });
- await user.keyboard('{Enter}');
- expect(screen.getByRole('menu')).toBeVisible();
-
- const item = screen.getByText('item1').parentElement;
- await waitFor(() => {
- expect(item.parentElement).toHaveFocus();
- });
-
- await user.keyboard('{Tab}');
- await waitFor(() => {
- expect(item.parentElement).toHaveFocus();
- });
- });
-
it('reset inputValue, when focus shifts from inside the component to outside', async () => {
const user = userEvent.setup();
render(
<>
- {renderChildren}
+
+ {renderChildren}
+
>
);
- const input = screen.getByLabelText('md-combo-box-input');
- act(()=>{
+ const input = screen.getByRole('combobox');
+ act(() => {
input.focus();
});
- await user.keyboard('{Escape}');
- expect(input).toHaveProperty('value','');
+ await user.keyboard('{i}');
+ expect(input).toHaveProperty('value', 'item1i');
const button = screen.getByRole('button', { name: 'button-outside' });
- act(()=>{
+ act(() => {
button.focus();
});
await waitFor(() => {
- expect(input).toHaveProperty('value','item1');
+ expect(input).toHaveProperty('value', 'item1');
});
});
- it('reset inputValue, when mousedown outside the component', async () => {
+ it('reset inputValue, when click outside the component', async () => {
const user = userEvent.setup();
render(
<>
- {renderChildren}
+
+ {renderChildren}
+
>
);
- const input = screen.getByLabelText('md-combo-box-input');
-
+ const input = screen.getByRole('combobox');
await waitFor(() => {
- expect(input).toHaveProperty('value','item1');
+ expect(input).toHaveProperty('value', 'item1');
});
- act(()=>{
+ act(() => {
input.focus();
});
await user.keyboard('{1}');
await waitFor(() => {
- expect(input).toHaveProperty('value','item11');
+ expect(input).toHaveProperty('value', 'item11');
});
const button = screen.getByRole('button', { name: 'button-outside' });
await user.click(button);
await waitFor(() => {
- expect(input).toHaveProperty('value','item1');
+ expect(input).toHaveProperty('value', 'item1');
});
});
- it('filter list when the input is focused', async () => {
+ it('filter list when input', async () => {
const user = userEvent.setup();
render(
<>
- {renderChildren}
+
+ {renderChildren}
+
>
);
- const input = screen.getByLabelText('md-combo-box-input');
- const button = screen
- .queryAllByRole('button')
- .find((button) => button.classList.contains('md-combo-box-button'));
+ const input = screen.getByRole('combobox');
await waitFor(() => {
- expect(input).toHaveProperty('value','item1');
+ expect(input).toHaveProperty('value', 'item1');
});
- await user.click(button);
- const menu = screen.getByRole('menu');
- expect(menu).toBeVisible();
- const item2 = screen.getByText('item2').parentElement;
- await user.click(button);
- await user.click(input);
- await user.keyboard('{Enter}');
- expect(input).toHaveProperty('value','item1');
- const item1 = screen.getByText('item1').parentElement;
+ act(() => {
+ input.focus();
+ });
+ await user.keyboard('{ArrowDown}');
+ const listbox = screen.getByRole('listbox');
+ expect(listbox).toBeVisible();
+ const item2 = screen.getAllByRole('option')[1];
+ const item1 = screen.getAllByRole('option')[0];
+ expect(item1).toBeInTheDocument();
+ expect(item2).toBeInTheDocument();
+ await user.keyboard('{Backspace}');
+ await user.keyboard('{1}');
+ expect(input).toHaveProperty('value', 'item1');
await waitFor(() => {
- expect(item1.parentElement).toHaveFocus();
+ expect(item1).toBeInTheDocument();
expect(item2).not.toBeInTheDocument();
});
});
- });
+ });
});
});
diff --git a/src/components/ComboBox/ComboBox.unit.test.tsx.snap b/src/components/ComboBox/ComboBox.unit.test.tsx.snap
index 2ce11bf61..726035619 100644
--- a/src/components/ComboBox/ComboBox.unit.test.tsx.snap
+++ b/src/components/ComboBox/ComboBox.unit.test.tsx.snap
@@ -2,175 +2,406 @@
exports[`ComboBox snapshot should match snapshot label 1`] = `
-
- comboBox_label
-
-
-
-
+
+ comboBox_label
+
+
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+ }
+ >
+
-
-
-
+
-
+
-
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+