)}
- />
- );
- }
-
- function Test() {
- return (
-
-
-
- );
- }
-
- render(
);
-
- const tabsList = screen.getByTestId('test-tabslist');
- expect(tabsList).not.to.equal(null);
-
- fireEvent.click(tabsList);
- expect(handleClick.callCount).to.equal(1);
- });
- });
-});
diff --git a/packages/react/src/Tabs/TabsList/useTabsList.ts b/packages/react/src/Tabs/TabsList/useTabsList.ts
index 846b111d5a..87bd847ffa 100644
--- a/packages/react/src/Tabs/TabsList/useTabsList.ts
+++ b/packages/react/src/Tabs/TabsList/useTabsList.ts
@@ -1,179 +1,56 @@
'use client';
import * as React from 'react';
-import { TabsListActionTypes, tabsListReducer, ValueChangeAction } from './tabsListReducer';
-import { useTabsRootContext } from '../Root/TabsRootContext';
-import { type TabMetadata } from '../Root/useTabsRoot';
+import type { TabsRootContext } from '../Root/TabsRootContext';
import { type TabsOrientation, type TabActivationDirection } from '../Root/TabsRoot';
-import { useCompoundParent } from '../../useCompound';
-import { useList, ListState, UseListParameters, ListAction } from '../../useList';
-import { useForkRef } from '../../utils/useForkRef';
import { mergeReactProps } from '../../utils/mergeReactProps';
+import { GenericHTMLProps } from '../../utils/types';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
-import { TabsDirection } from '../Root/TabsRoot';
-import { TabsListProviderValue } from './TabsListProvider';
+import { useForkRef } from '../../utils/useForkRef';
+import { useEventCallback } from '../../utils/useEventCallback';
function useTabsList(parameters: useTabsList.Parameters): useTabsList.ReturnValue {
- const { rootRef: externalRef, loop, activateOnFocus } = parameters;
-
const {
- direction = 'ltr',
- onSelected,
- orientation = 'horizontal',
- value,
- registerTabIdLookup,
- tabActivationDirection,
- } = useTabsRootContext();
-
- const { subitems, contextValue: compoundComponentContextValue } = useCompoundParent<
- any,
- TabMetadata
- >();
-
- const tabIdLookup = React.useCallback(
- (tabValue: any) => {
- return subitems.get(tabValue)?.id;
- },
- [subitems],
- );
-
- registerTabIdLookup(tabIdLookup);
-
- const subitemKeys = React.useMemo(() => Array.from(subitems.keys()), [subitems]);
-
- const getTabElement = React.useCallback(
- (tabValue: any) => {
- if (tabValue == null) {
- return null;
- }
-
- return subitems.get(tabValue)?.ref.current ?? null;
- },
- [subitems],
- );
-
- let listOrientation: UseListParameters
['orientation'];
- if (orientation === 'vertical') {
- listOrientation = 'vertical';
- } else {
- listOrientation = direction === 'rtl' ? 'horizontal-rtl' : 'horizontal-ltr';
- }
+ getTabElementBySelectedValue,
+ onValueChange,
+ orientation,
+ rootRef: externalRef,
+ tabsListRef,
+ value: selectedTabValue,
+ } = parameters;
- const tabsListRef = React.useRef(null);
const detectActivationDirection = useActivationDirectionDetector(
- value,
+ // the old value
+ selectedTabValue,
orientation,
tabsListRef,
- getTabElement,
+ getTabElementBySelectedValue,
);
- const handleChange = React.useCallback(
- (
- event:
- | React.FocusEvent
- | React.KeyboardEvent
- | React.MouseEvent
- | null,
- newValue: any[],
- ) => {
- const newSelectedValue = newValue[0] ?? null;
- const activationDirection = detectActivationDirection(newSelectedValue);
- onSelected(event?.nativeEvent, newValue[0] ?? null, activationDirection);
- },
- [onSelected, detectActivationDirection],
- );
-
- const controlledProps = React.useMemo(() => {
- return value != null ? { selectedValues: [value] } : { selectedValues: [] };
- }, [value]);
-
- const isItemDisabled = React.useCallback(
- (item: any) => subitems.get(item)?.disabled ?? false,
- [subitems],
- );
-
- const {
- contextValue: listContextValue,
- dispatch,
- getRootProps: getListboxRootProps,
- state: { highlightedValue, selectedValues },
- rootRef: mergedRootRef,
- } = useList, ValueChangeAction, { activateOnFocus: boolean }>({
- controlledProps,
- disabledItemsFocusable: !activateOnFocus,
- focusManagement: 'DOM',
- getItemDomElement: getTabElement,
- isItemDisabled,
- items: subitemKeys,
- rootRef: externalRef,
- onChange: handleChange,
- orientation: listOrientation,
- reducerActionContext: React.useMemo(() => ({ activateOnFocus }), [activateOnFocus]),
- selectionMode: 'single',
- stateReducer: tabsListReducer,
- disableListWrap: !loop,
+ const onTabActivation = useEventCallback((newValue: any, event: Event) => {
+ if (newValue !== selectedTabValue) {
+ const activationDirection = detectActivationDirection(newValue);
+ onValueChange(newValue, activationDirection, event);
+ }
});
- React.useEffect(() => {
- if (value === undefined) {
- return;
- }
+ const handleRef = useForkRef(tabsListRef, externalRef);
- // when a value changes externally, the highlighted value should be synced to it
- if (value != null) {
- dispatch({
- type: TabsListActionTypes.valueChange,
- value,
+ const getRootProps = React.useCallback(
+ (otherProps = {}): React.ComponentPropsWithRef<'div'> => {
+ return mergeReactProps(otherProps, {
+ 'aria-orientation': orientation === 'vertical' ? 'vertical' : undefined,
+ ref: handleRef,
+ role: 'tablist',
});
- }
- }, [dispatch, value]);
-
- const handleRef = useForkRef(mergedRootRef, tabsListRef);
-
- const getRootProps = (
- externalProps: React.ComponentPropsWithoutRef<'div'> = {},
- ): React.ComponentPropsWithRef<'div'> => {
- return mergeReactProps(
- externalProps,
- mergeReactProps(
- {
- 'aria-orientation': orientation === 'vertical' ? 'vertical' : undefined,
- role: 'tablist',
- ref: handleRef,
- },
- getListboxRootProps(),
- ),
- );
- };
-
- const contextValue = React.useMemo(
- () => ({
- ...compoundComponentContextValue,
- ...listContextValue,
- activateOnFocus,
- getTabElement,
- value,
- tabsListRef,
- }),
- [
- compoundComponentContextValue,
- listContextValue,
- activateOnFocus,
- getTabElement,
- value,
- tabsListRef,
- ],
+ },
+ [handleRef, orientation],
);
return {
- contextValue,
- dispatch,
getRootProps,
- highlightedValue,
- direction,
- orientation,
+ onTabActivation,
rootRef: handleRef,
- selectedValue: selectedValues[0] ?? null,
- tabActivationDirection,
+ tabsListRef,
};
}
@@ -188,21 +65,22 @@ function getInset(tab: HTMLElement, tabsList: HTMLElement) {
}
function useActivationDirectionDetector(
- value: any,
+ // the old value
+ selectedTabValue: any,
orientation: TabsOrientation,
tabsListRef: React.RefObject,
- getTabElement: (tabValue: any) => HTMLElement | null,
+ getTabElement: (selectedValue: any) => HTMLElement | null,
): (newValue: any) => TabActivationDirection {
const previousTabEdge = React.useRef(null);
useEnhancedEffect(() => {
// Whenever orientation changes, reset the state.
- if (value == null || tabsListRef.current == null) {
+ if (selectedTabValue == null || tabsListRef.current == null) {
previousTabEdge.current = null;
return;
}
- const activeTab = getTabElement(value);
+ const activeTab = getTabElement(selectedTabValue);
if (activeTab == null) {
previousTabEdge.current = null;
return;
@@ -210,11 +88,11 @@ function useActivationDirectionDetector(
const { left, top } = getInset(activeTab, tabsListRef.current);
previousTabEdge.current = orientation === 'horizontal' ? left : top;
- }, [orientation, getTabElement, tabsListRef, value]);
+ }, [orientation, getTabElement, tabsListRef, selectedTabValue]);
return React.useCallback(
(newValue: any) => {
- if (newValue === value) {
+ if (newValue === selectedTabValue) {
return 'none';
}
@@ -255,63 +133,38 @@ function useActivationDirectionDetector(
return 'none';
},
- [getTabElement, orientation, previousTabEdge, tabsListRef, value],
+ [getTabElement, orientation, previousTabEdge, tabsListRef, selectedTabValue],
);
}
namespace useTabsList {
- export interface Parameters {
- /**
- * If `true`, the tab will be activated whenever it is focused.
- * Otherwise, it has to be activated by clicking or pressing the Enter or Space key.
- */
- activateOnFocus: boolean;
- /**
- * If `true`, using keyboard navigation will wrap focus to the other end of the list once the end is reached.
- */
- loop: boolean;
+ export interface Parameters
+ extends Pick<
+ TabsRootContext,
+ 'getTabElementBySelectedValue' | 'onValueChange' | 'orientation' | 'setTabMap' | 'value'
+ > {
/**
* Ref to the root element.
*/
rootRef: React.Ref;
+ tabsListRef: React.RefObject;
}
export interface ReturnValue {
/**
- * The value to be passed to the TabListProvider above all the tabs.
- */
- contextValue: TabsListProviderValue;
- /**
- * Action dispatcher for the tabs list component.
- * Allows to programmatically control the tabs list.
- */
- dispatch: (action: ListAction) => void;
- /**
- * Resolver for the root slot's props.
- * @param externalProps props for the root slot
- * @returns props that should be spread on the root slot
+ * Resolver for the TabsList component's props.
+ * @param externalProps additional props for Tabs.TabsList
+ * @returns props that should be spread on Tabs.TabsList
*/
- getRootProps: (
- externalProps?: React.ComponentPropsWithRef<'div'>,
- ) => React.ComponentPropsWithRef<'div'>;
+ getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps;
/**
- * The value of the currently highlighted tab.
+ * Callback when a Tab is activated
+ * @param {any | null} newValue The value of the newly activated tab.
+ * @param {Event} event The event that activated the Tab.
*/
- highlightedValue: any | null;
- /**
- * If `true`, it will indicate that the text's direction in right-to-left.
- */
- direction: TabsDirection;
- /**
- * The component orientation (layout flow direction).
- */
- orientation: TabsOrientation;
+ onTabActivation: (newValue: any, event: Event) => void;
rootRef: React.RefCallback | null;
- /**
- * The value of the currently selected tab.
- */
- selectedValue: any | null;
- tabActivationDirection: TabActivationDirection;
+ tabsListRef: React.RefObject;
}
}
diff --git a/packages/react/src/useCompound/index.ts b/packages/react/src/useCompound/index.ts
deleted file mode 100644
index 2fea72863a..0000000000
--- a/packages/react/src/useCompound/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './useCompoundParent';
-export * from './useCompoundItem';
diff --git a/packages/react/src/useCompound/useCompound.test.tsx b/packages/react/src/useCompound/useCompound.test.tsx
deleted file mode 100644
index d508805cbf..0000000000
--- a/packages/react/src/useCompound/useCompound.test.tsx
+++ /dev/null
@@ -1,281 +0,0 @@
-import * as React from 'react';
-import { expect } from 'chai';
-import { render } from '@mui/internal-test-utils';
-import { CompoundComponentContext, useCompoundParent } from './useCompoundParent';
-import { useCompoundItem } from './useCompoundItem';
-
-type ItemValue = { value: string; ref: React.RefObject };
-
-describe('compound components', () => {
- describe('useCompoundParent', () => {
- it('knows about children from the whole subtree', () => {
- let parentSubitems: Map;
-
- function Parent(props: React.PropsWithChildren<{}>) {
- const { children } = props;
- const { subitems, contextValue } = useCompoundParent();
-
- parentSubitems = subitems;
-
- return (
-
- {children}
-
- );
- }
-
- function Child(props: React.PropsWithChildren<{ id: string; value: string }>) {
- const { id, value, children } = props;
- const ref = React.useRef(null);
- useCompoundItem(
- id,
- React.useMemo(() => ({ value, ref }), [value]),
- );
-
- return {children};
- }
-
- render(
-
-
-
-
-
-
-
-
-
-
-
- ,
- );
-
- expect(Array.from(parentSubitems!.keys())).to.deep.equal([
- '1',
- '2',
- '2.1',
- '2.2',
- '3',
- '3.1',
- '3.1.1',
- ]);
-
- expect(Array.from(parentSubitems!.values()).map((v) => v.value)).to.deep.equal([
- 'one',
- 'two',
- 'two.one',
- 'two.two',
- 'three',
- 'three.one',
- 'three.one.one',
- ]);
- });
-
- it('knows about children rendered by other components', () => {
- let parentSubitems: Map;
-
- function Parent(props: React.PropsWithChildren<{}>) {
- const { children } = props;
- const { subitems, contextValue } = useCompoundParent();
-
- parentSubitems = subitems;
-
- return (
-
- {children}
-
- );
- }
-
- function Child(props: { id: string; value: string }) {
- const { id, value } = props;
- const ref = React.useRef(null);
- useCompoundItem(
- id,
- React.useMemo(() => ({ value, ref }), [value]),
- );
-
- return ;
- }
-
- function Wrapper() {
- return (
-
-
-
-
-
- );
- }
-
- render(
-
-
-
-
- ,
- );
-
- expect(Array.from(parentSubitems!.keys())).to.deep.equal(['0', '1', '2', '3', '4']);
-
- expect(Array.from(parentSubitems!.values()).map((v) => v.value)).to.deep.equal([
- 'zero',
- 'one',
- 'two',
- 'three',
- 'four',
- ]);
- });
-
- // https://github.com/mui/material-ui/issues/36800
- it('maintains the correct order of children when they are inserted in the middle', () => {
- let parentSubitems: Map;
- let subitemsToRender = ['1', '4', '5'];
-
- function Parent(props: React.PropsWithChildren<{}>) {
- const { children } = props;
- const { subitems, contextValue } = useCompoundParent();
-
- parentSubitems = subitems;
-
- return (
-
- {children}
-
- );
- }
-
- function Child(props: React.PropsWithChildren<{ id: string; value: string }>) {
- const { id, value, children } = props;
- const ref = React.useRef(null);
- useCompoundItem(
- id,
- React.useMemo(() => ({ value, ref }), [value]),
- );
-
- return {children};
- }
-
- const { rerender } = render(
-
- {subitemsToRender.map((item) => (
-
- ))}
- ,
- );
-
- subitemsToRender = ['1', '2', '3', '4', '5'];
-
- rerender(
-
- {subitemsToRender.map((item) => (
-
- ))}
- ,
- );
-
- expect(Array.from(parentSubitems!.keys())).to.deep.equal(['1', '2', '3', '4', '5']);
- });
-
- // TODO: test if removed children are removed from the map
-
- // TODO: test if parent is notified about updated metadata
- });
-
- describe('useCompoundItem', () => {
- it('knows its position within the parent and total number of registered items', () => {
- function Parent(props: React.PropsWithChildren<{}>) {
- const { children } = props;
- const { contextValue } = useCompoundParent<
- string,
- { ref: React.RefObject }
- >();
-
- return (
-
- {children}
-
- );
- }
-
- function Child() {
- const id = React.useId();
- const ref = React.useRef(null);
- const { index, totalItemCount } = useCompoundItem(
- id,
- React.useMemo(() => ({ ref }), []),
- );
-
- return (
-
- {index + 1} of {totalItemCount}
-
- );
- }
-
- const { getAllByTestId } = render(
-
-
-
-
- Unrelated element 1
-
-
- Unrelated element 2
-
-
-
- ,
- );
-
- const children = getAllByTestId('child');
-
- children.forEach((child, index) => {
- expect(child.innerHTML).to.equal(`${index + 1} of 4`);
- });
- });
-
- it('gets assigned a generated id if none is provided', () => {
- function Parent(props: React.PropsWithChildren<{}>) {
- const { children } = props;
- const { contextValue } = useCompoundParent<
- number,
- { ref: React.RefObject }
- >();
-
- return (
-
-
-
- );
- }
-
- function idGenerator(existingIds: Set) {
- return `item-${existingIds.size}`;
- }
-
- function Child() {
- const ref = React.useRef(null);
- const { id } = useCompoundItem }>(
- idGenerator,
- React.useMemo(() => ({ ref }), []),
- );
-
- return {id};
- }
-
- const { getAllByRole } = render(
-
-
-
-
- ,
- );
-
- const children = getAllByRole('listitem');
- expect(children[0].innerHTML).to.equal('item-0');
- expect(children[1].innerHTML).to.equal('item-1');
- expect(children[2].innerHTML).to.equal('item-2');
- });
- });
-});
diff --git a/packages/react/src/useCompound/useCompoundItem.ts b/packages/react/src/useCompound/useCompoundItem.ts
deleted file mode 100644
index 1c65a8d8da..0000000000
--- a/packages/react/src/useCompound/useCompoundItem.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-'use client';
-import * as React from 'react';
-import { unstable_useEnhancedEffect as useEnhancedEffect } from '@mui/utils';
-import {
- CompoundComponentContext,
- CompoundComponentContextValue,
- KeyGenerator,
-} from './useCompoundParent';
-
-export interface UseCompoundItemReturnValue {
- /**
- * The unique key for the child component.
- * If the id was provided to `useCompoundItem`, this will be the same value.
- * Otherwise, this will be a value generated by the `id` function.
- */
- id: Key | undefined;
- /**
- * The 0-based index of the child component in the parent component's list of registered children.
- */
- index: number;
- /**
- * The total number of child components registered with the parent component.
- * This value will be correct after the effect phase of the component (as children are registered with the parent during the effect phase).
- */
- totalItemCount: number;
-}
-
-/**
- * Registers a child component with the parent component.
- *
- * @param id A unique key for the child component. If the `id` is `undefined`, the registration logic will not run (this can sometimes be the case during SSR).
- * This can be either a value, or a function that generates a value based on already registered siblings' ids.
- * If a function, it's called with the set of the ids of all the items that have already been registered.
- * Return `existingKeys.size` if you want to use the index of the new item as the id.
- * @param itemMetadata Arbitrary metadata to pass to the parent component. This should be a stable reference (for example a memoized object), to avoid unnecessary re-registrations.
- *
- * @ignore - internal hook.
- */
-export function useCompoundItem(
- id: Key | KeyGenerator,
- itemMetadata: Subitem,
-): UseCompoundItemReturnValue {
- const context = React.useContext(CompoundComponentContext) as CompoundComponentContextValue<
- Key,
- Subitem
- >;
-
- if (context === null) {
- throw new Error('useCompoundItem must be used within a useCompoundParent');
- }
-
- const { registerItem } = context;
- const [registeredId, setRegisteredId] = React.useState(typeof id === 'function' ? undefined : id);
-
- useEnhancedEffect(() => {
- const { id: returnedId, deregister } = registerItem(id, itemMetadata);
- setRegisteredId(returnedId);
- return deregister;
- }, [registerItem, itemMetadata, id]);
-
- return {
- id: registeredId,
- index: registeredId !== undefined ? context.getItemIndex(registeredId) : -1,
- totalItemCount: context.totalSubitemCount,
- };
-}
diff --git a/packages/react/src/useCompound/useCompoundParent.ts b/packages/react/src/useCompound/useCompoundParent.ts
deleted file mode 100644
index 99f0b20827..0000000000
--- a/packages/react/src/useCompound/useCompoundParent.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-'use client';
-import * as React from 'react';
-
-interface RegisterItemReturnValue {
- /**
- * The id of the item.
- */
- id: Key;
- /**
- * A function that deregisters the item.
- */
- deregister: () => void;
-}
-
-export type KeyGenerator = (existingKeys: Set) => Key;
-
-export type CompoundComponentContextValue = {
- /**
- * Registers an item with the parent.
- * This should be called during the effect phase of the child component.
- * The `itemMetadata` should be a stable reference (for example a memoized object), to avoid unnecessary re-registrations.
- *
- * @param id Id of the item or A function that generates a unique id for the item.
- * It is called with the set of the ids of all the items that have already been registered.
- * Return `existingKeys.size` if you want to use the index of the new item as the id..
- * @param itemMetadata Arbitrary metadata to pass to the parent component.
- */
- registerItem: (id: Key | KeyGenerator, item: Subitem) => RegisterItemReturnValue;
- /**
- * Returns the 0-based index of the item in the parent component's list of registered items.
- *
- * @param id id of the item.
- */
- getItemIndex: (id: Key) => number;
- /**
- * The total number of items registered with the parent.
- */
- totalSubitemCount: number;
-};
-
-export const CompoundComponentContext = React.createContext | null>(null);
-
-if (process.env.NODE_ENV !== 'production') {
- CompoundComponentContext.displayName = 'CompoundComponentContext';
-}
-
-export interface UseCompoundParentReturnValue<
- Key,
- Subitem extends { ref: React.RefObject },
-> {
- /**
- * The value for the CompoundComponentContext provider.
- */
- contextValue: CompoundComponentContextValue;
- /**
- * The subitems registered with the parent.
- * The key is the id of the subitem, and the value is the metadata passed to the `useCompoundItem` hook.
- * The order of the items is the same as the order in which they were registered.
- */
- subitems: Map;
-}
-
-/**
- * Sorts the subitems by their position in the DOM.
- */
-function sortSubitems }>(
- subitems: Map,
-) {
- const subitemsArray = Array.from(subitems.keys()).map((key) => {
- const subitem = subitems.get(key)!;
- return { key, subitem };
- });
-
- subitemsArray.sort((a, b) => {
- const aNode = a.subitem.ref.current;
- const bNode = b.subitem.ref.current;
-
- if (aNode === null || bNode === null || aNode === bNode) {
- return 0;
- }
-
- // eslint-disable-next-line no-bitwise
- return aNode.compareDocumentPosition(bNode) & Node.DOCUMENT_POSITION_PRECEDING ? 1 : -1;
- });
-
- return new Map(subitemsArray.map((item) => [item.key, item.subitem]));
-}
-
-/**
- * Provides a way for a component to know about its children.
- *
- * Child components register themselves with the `useCompoundItem` hook, passing in arbitrary metadata to the parent.
- *
- * This is a more powerful altervantive to `children` traversal, as child components don't have to be placed
- * directly inside the parent component. They can be anywhere in the tree (and even rendered by other components).
- *
- * The downside is that this doesn't work with SSR as it relies on the useEffect hook.
- *
- * @ignore - internal hook.
- */
-export function useCompoundParent<
- Key,
- Subitem extends { ref: React.RefObject },
->(): UseCompoundParentReturnValue {
- const [subitems, setSubitems] = React.useState(new Map());
- const subitemKeys = React.useRef(new Set());
-
- const deregisterItem = React.useCallback(function deregisterItem(id: Key) {
- subitemKeys.current.delete(id);
- setSubitems((previousState) => {
- const newState = new Map(previousState);
- newState.delete(id);
- return newState;
- });
- }, []);
-
- const registerItem = React.useCallback(
- function registerItem(id: Key | KeyGenerator, item: Subitem) {
- let providedOrGeneratedId: Key;
-
- if (typeof id === 'function') {
- providedOrGeneratedId = (id as KeyGenerator)(subitemKeys.current);
- } else {
- providedOrGeneratedId = id;
- }
-
- subitemKeys.current.add(providedOrGeneratedId);
- setSubitems((previousState) => {
- const newState = new Map(previousState);
- newState.set(providedOrGeneratedId, item);
- return newState;
- });
-
- return {
- id: providedOrGeneratedId,
- deregister: () => deregisterItem(providedOrGeneratedId),
- };
- },
- [deregisterItem],
- );
-
- const sortedSubitems = React.useMemo(() => sortSubitems(subitems), [subitems]);
-
- const getItemIndex = React.useCallback(
- function getItemIndex(id: Key) {
- return Array.from(sortedSubitems.keys()).indexOf(id);
- },
- [sortedSubitems],
- );
-
- const contextValue = React.useMemo(
- () => ({
- getItemIndex,
- registerItem,
- totalSubitemCount: subitems.size,
- }),
- [getItemIndex, registerItem, subitems.size],
- );
-
- return {
- contextValue,
- subitems: sortedSubitems,
- };
-}
diff --git a/packages/react/src/useList/ListContext.ts b/packages/react/src/useList/ListContext.ts
deleted file mode 100644
index 323a10023a..0000000000
--- a/packages/react/src/useList/ListContext.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-'use client';
-import * as React from 'react';
-import { ListAction } from './listActions.types';
-import { ListItemState } from './useList.types';
-
-export interface ListContextValue {
- dispatch: (action: ListAction) => void;
- getItemState: (item: ItemValue) => ListItemState;
-}
-
-export const ListContext = React.createContext | null>(null);
-if (process.env.NODE_ENV !== 'production') {
- ListContext.displayName = 'ListContext';
-}
diff --git a/packages/react/src/useList/index.ts b/packages/react/src/useList/index.ts
deleted file mode 100644
index 1d6568987e..0000000000
--- a/packages/react/src/useList/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export { useList } from './useList';
-export * from './useList.types';
-
-export { useListItem } from './useListItem';
-export * from './useListItem.types';
-
-export * from './listReducer';
-export * from './listActions.types';
-export * from './ListContext';
diff --git a/packages/react/src/useList/listActions.types.ts b/packages/react/src/useList/listActions.types.ts
deleted file mode 100644
index 848858100c..0000000000
--- a/packages/react/src/useList/listActions.types.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-export const ListActionTypes = {
- blur: 'list:blur',
- focus: 'list:focus',
- itemClick: 'list:itemClick',
- itemHover: 'list:itemHover',
- itemsChange: 'list:itemsChange',
- keyDown: 'list:keyDown',
- resetHighlight: 'list:resetHighlight',
- highlightLast: 'list:highlightLast',
- textNavigation: 'list:textNavigation',
- clearSelection: 'list:clearSelection',
-} as const;
-
-interface ItemClickAction {
- type: typeof ListActionTypes.itemClick;
- item: ItemValue;
- event: React.MouseEvent;
-}
-
-interface ItemHoverAction {
- type: typeof ListActionTypes.itemHover;
- item: ItemValue;
- event: React.MouseEvent;
-}
-
-interface FocusAction {
- type: typeof ListActionTypes.focus;
- event: React.FocusEvent;
-}
-
-interface BlurAction {
- type: typeof ListActionTypes.blur;
- event: React.FocusEvent;
-}
-
-interface KeyDownAction {
- type: typeof ListActionTypes.keyDown;
- key: string;
- event: React.KeyboardEvent;
-}
-
-interface TextNavigationAction {
- type: typeof ListActionTypes.textNavigation;
- event: React.KeyboardEvent;
- searchString: string;
-}
-
-interface ItemsChangeAction {
- type: typeof ListActionTypes.itemsChange;
- event: null;
- items: ItemValue[];
- previousItems: ItemValue[];
-}
-
-interface ResetHighlightAction {
- type: typeof ListActionTypes.resetHighlight;
- event: React.SyntheticEvent | null;
-}
-
-interface HighlightLastAction {
- type: typeof ListActionTypes.highlightLast;
- event: React.SyntheticEvent | null;
-}
-
-interface ClearSelectionAction {
- type: typeof ListActionTypes.clearSelection;
-}
-
-/**
- * A union of all standard actions that can be dispatched to the list reducer.
- */
-export type ListAction =
- | BlurAction
- | FocusAction
- | ItemClickAction
- | ItemHoverAction
- | ItemsChangeAction
- | KeyDownAction
- | ResetHighlightAction
- | HighlightLastAction
- | TextNavigationAction
- | ClearSelectionAction;
diff --git a/packages/react/src/useList/listReducer.test.ts b/packages/react/src/useList/listReducer.test.ts
deleted file mode 100644
index 1a60ac1f74..0000000000
--- a/packages/react/src/useList/listReducer.test.ts
+++ /dev/null
@@ -1,1230 +0,0 @@
-import * as React from 'react';
-import { expect } from 'chai';
-import { listReducer } from './listReducer';
-import { ListReducerAction, ListState } from './useList.types';
-import { ListActionTypes } from './listActions.types';
-
-describe('listReducer', () => {
- describe('action: blur', () => {
- it('resets the highlightedValue', () => {
- const state: ListState = {
- highlightedValue: 'a',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.blur,
- event: {} as any, // not relevant
- context: {
- items: [],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal(null);
- });
- });
-
- describe('action: itemClick', () => {
- it('sets the selectedValues to the clicked value', () => {
- const state: ListState = {
- highlightedValue: 'a',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemClick,
- event: {} as any, // not relevant
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- item: 'two',
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['two']);
- });
-
- it('does not select a disabled item', () => {
- const state: ListState = {
- highlightedValue: null,
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemClick,
- event: {} as any, // not relevant
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: (item) => item === 'two',
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- item: 'two',
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal(null);
- expect(result.selectedValues).to.deep.equal([]);
- });
-
- it('replaces the selectedValues with the clicked value if selectionMode = "single"', () => {
- const state: ListState = {
- highlightedValue: 'a',
- selectedValues: ['one'],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemClick,
- event: {} as any, // not relevant
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- item: 'two',
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['two']);
- });
-
- it('add the clicked value to the selection if selectionMode = "multiple"', () => {
- const state: ListState = {
- highlightedValue: 'one',
- selectedValues: ['one'],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemClick,
- event: {} as any, // not relevant
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'multiple',
- },
- item: 'two',
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['one', 'two']);
- });
-
- it('remove the clicked value from the selection if selectionMode = "multiple" and it was selected already', () => {
- const state: ListState = {
- highlightedValue: 'three',
- selectedValues: ['one', 'two'],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemClick,
- event: {} as any, // not relevant
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'multiple',
- },
- item: 'two',
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['one']);
- });
-
- it('does not select the clicked value to the selection if selectionMode = "none"', () => {
- const state: ListState = {
- highlightedValue: 'one',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemClick,
- event: {} as any, // not relevant
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'none',
- },
- item: 'two',
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal([]);
- });
- });
-
- describe('action: keyDown', () => {
- interface KeydownTestCase {
- description: string;
- key: string;
- initialHighlightedItem: string | null;
- disabledItemFocusable: boolean;
- disabledItems: string[];
- disableListWrap: boolean;
- expected: string | null;
- }
-
- const testCases: KeydownTestCase[] = [
- {
- description: 'happy path',
- key: 'Home',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'highlights the first enabled item',
- key: 'Home',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['1'],
- disableListWrap: false,
- expected: '2',
- },
- {
- description: 'highlights the first enabled item',
- key: 'Home',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['1', '2'],
- disableListWrap: false,
- expected: '3',
- },
- {
- description: 'highlights the first item even if it is disabled',
- key: 'Home',
- initialHighlightedItem: null,
- disabledItemFocusable: true,
- disabledItems: ['1'],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'all disabled',
- key: 'Home',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['1', '2', '3', '4', '5'],
- disableListWrap: false,
- expected: null,
- },
- {
- description: 'all disabled but focusable',
- key: 'Home',
- initialHighlightedItem: null,
- disabledItemFocusable: true,
- disabledItems: ['1', '2', '3', '4', '5'],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'happy path',
- key: 'End',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '5',
- },
- {
- description: 'highlights the last enabled item',
- key: 'End',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['5'],
- disableListWrap: false,
- expected: '4',
- },
- {
- description: 'highlights the last enabled item',
- key: 'End',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['4', '5'],
- disableListWrap: false,
- expected: '3',
- },
- {
- description: 'highlights the last item even if it is disabled',
- key: 'End',
- initialHighlightedItem: null,
- disabledItemFocusable: true,
- disabledItems: ['5'],
- disableListWrap: false,
- expected: '5',
- },
- {
- description: 'all disabled',
- key: 'End',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['1', '2', '3', '4', '5'],
- disableListWrap: false,
- expected: null,
- },
- {
- description: 'all disabled but focusable',
- key: 'End',
- initialHighlightedItem: null,
- disabledItemFocusable: true,
- disabledItems: ['1', '2', '3', '4', '5'],
- disableListWrap: false,
- expected: '5',
- },
- {
- description: 'happy path',
- key: 'ArrowDown',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'happy path',
- key: 'ArrowDown',
- initialHighlightedItem: '1',
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '2',
- },
- {
- description: 'skips the disabled item',
- key: 'ArrowDown',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['1'],
- disableListWrap: false,
- expected: '2',
- },
- {
- description: 'skips multiple disabled items',
- key: 'ArrowDown',
- initialHighlightedItem: '1',
- disabledItemFocusable: false,
- disabledItems: ['2', '3'],
- disableListWrap: false,
- expected: '4',
- },
- {
- description: 'skips the disabled items and wraps around',
- key: 'ArrowDown',
- initialHighlightedItem: '3',
- disabledItemFocusable: false,
- disabledItems: ['1', '4', '5'],
- disableListWrap: false,
- expected: '2',
- },
- {
- description: 'focuses the disabled item',
- key: 'ArrowDown',
- initialHighlightedItem: null,
- disabledItemFocusable: true,
- disabledItems: ['1'],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'does not wrap around',
- key: 'ArrowDown',
- initialHighlightedItem: '5',
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: true,
- expected: '5',
- },
- {
- description: 'remains on the same item when all the next are disabled',
- key: 'ArrowDown',
- initialHighlightedItem: '3',
- disabledItemFocusable: false,
- disabledItems: ['4', '5'],
- disableListWrap: true,
- expected: '3',
- },
- {
- description: 'all disabled',
- key: 'ArrowDown',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['1', '2', '3', '4', '5'],
- disableListWrap: false,
- expected: null,
- },
- {
- description: 'all disabled but focusable',
- key: 'ArrowDown',
- initialHighlightedItem: null,
- disabledItemFocusable: true,
- disabledItems: ['1', '2', '3', '4', '5'],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'happy path',
- key: 'ArrowUp',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '5',
- },
- {
- description: 'happy path',
- key: 'ArrowUp',
- initialHighlightedItem: '2',
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'skips the disabled item',
- key: 'ArrowUp',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['5'],
- disableListWrap: false,
- expected: '4',
- },
- {
- description: 'skips multiple disabled items',
- key: 'ArrowUp',
- initialHighlightedItem: '1',
- disabledItemFocusable: false,
- disabledItems: ['4', '5'],
- disableListWrap: false,
- expected: '3',
- },
- {
- description: 'skips the disabled items and wraps around',
- key: 'ArrowUp',
- initialHighlightedItem: '2',
- disabledItemFocusable: false,
- disabledItems: ['1', '4', '5'],
- disableListWrap: false,
- expected: '3',
- },
- {
- description: 'focuses the disabled item',
- key: 'ArrowUp',
- initialHighlightedItem: null,
- disabledItemFocusable: true,
- disabledItems: ['5'],
- disableListWrap: false,
- expected: '5',
- },
- {
- description: 'does not wrap around',
- key: 'ArrowUp',
- initialHighlightedItem: '1',
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: true,
- expected: '1',
- },
- {
- description: 'remains on the same item when all the previous are disabled',
- key: 'ArrowUp',
- initialHighlightedItem: '3',
- disabledItemFocusable: false,
- disabledItems: ['1', '2'],
- disableListWrap: true,
- expected: '3',
- },
- {
- description: 'all disabled',
- key: 'ArrowUp',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['1', '2', '3', '4', '5'],
- disableListWrap: false,
- expected: null,
- },
- {
- description: 'all disabled but focusable',
- key: 'ArrowUp',
- initialHighlightedItem: null,
- disabledItemFocusable: true,
- disabledItems: ['1', '2', '3', '4', '5'],
- disableListWrap: false,
- expected: '5',
- },
- {
- description: 'happy path',
- key: 'PageDown',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '3',
- },
- {
- description: 'skips the disabled item',
- key: 'PageDown',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: ['3'],
- disableListWrap: false,
- expected: '4',
- },
- {
- description: 'does not wrap around, no matter the setting',
- key: 'PageDown',
- initialHighlightedItem: '4',
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '5',
- },
- {
- description: 'does not wrap around, no matter the setting, and skips the disabled item',
- key: 'PageDown',
- initialHighlightedItem: '3',
- disabledItemFocusable: false,
- disabledItems: ['5'],
- disableListWrap: false,
- expected: '4',
- },
- {
- description: 'happy path',
- key: 'PageUp',
- initialHighlightedItem: null,
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'skips the disabled item',
- key: 'PageUp',
- initialHighlightedItem: '5',
- disabledItemFocusable: false,
- disabledItems: ['2'],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'does not wrap around, no matter the setting',
- key: 'PageUp',
- initialHighlightedItem: '2',
- disabledItemFocusable: false,
- disabledItems: [],
- disableListWrap: false,
- expected: '1',
- },
- {
- description: 'does not wrap around, no matter the setting, and skips the disabled item',
- key: 'PageUp',
- initialHighlightedItem: '3',
- disabledItemFocusable: false,
- disabledItems: ['1'],
- disableListWrap: false,
- expected: '2',
- },
- ];
-
- testCases.forEach((spec: KeydownTestCase) => {
- describe(`given initialHighlightedItem: '${
- spec.initialHighlightedItem
- }', disabledItemsFocusable: ${spec.disabledItemFocusable}, disableListWrap: ${
- spec.disableListWrap
- }, disabledItems: [${spec.disabledItems.join()}]`, () => {
- it(`${spec.description}: should highlight the '${spec.expected}' item after the ${spec.key} is pressed`, () => {
- const state: ListState = {
- highlightedValue: spec.initialHighlightedItem,
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.keyDown,
- key: spec.key,
- event: null as any, // not relevant
- context: {
- items: ['1', '2', '3', '4', '5'],
- disableListWrap: spec.disableListWrap,
- disabledItemsFocusable: spec.disabledItemFocusable,
- focusManagement: 'activeDescendant',
- isItemDisabled: (item) => spec.disabledItems.includes(item),
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 3,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal(spec.expected);
- });
- });
- });
-
- describe('Enter key is pressed', () => {
- it('selects the highlighted option', () => {
- const state: ListState = {
- highlightedValue: 'two',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.keyDown,
- key: 'Enter',
- event: {} as any,
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['two']);
- });
-
- it('replaces the selectedValues with the highlighted value if selectionMode = "single"', () => {
- const state: ListState = {
- highlightedValue: 'two',
- selectedValues: ['one'],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.keyDown,
- key: 'Enter',
- event: {} as any,
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['two']);
- });
-
- it('add the highlighted value to the selection if selectionMode = "multiple"', () => {
- const state: ListState = {
- highlightedValue: 'two',
- selectedValues: ['one'],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.keyDown,
- key: 'Enter',
- event: {} as any,
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'multiple',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['one', 'two']);
- });
- });
- });
-
- describe('action: textNavigation', () => {
- it('should navigate to next match', () => {
- const state: ListState = {
- highlightedValue: 'two',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.textNavigation,
- searchString: 'th',
- event: {} as React.KeyboardEvent,
- context: {
- items: ['one', 'two', 'three', 'four', 'five'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('three');
- });
-
- it('should not move the highlight when there are no matched items', () => {
- const state: ListState = {
- highlightedValue: 'one',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.textNavigation,
- searchString: 'z',
- event: {} as React.KeyboardEvent,
- context: {
- items: ['one', 'two', 'three', 'four', 'five'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('one');
- });
-
- it('should highlight first match that is not disabled', () => {
- const state: ListState = {
- highlightedValue: 'one',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.textNavigation,
- searchString: 't',
- event: {} as React.KeyboardEvent,
- context: {
- items: ['one', 'two', 'three', 'four', 'five'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: (_, i) => i === 1,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('three');
- });
-
- it('should move highlight to disabled items if disabledItemsFocusable=true', () => {
- const state: ListState = {
- highlightedValue: 'one',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.textNavigation,
- searchString: 't',
- event: {} as React.KeyboardEvent,
- context: {
- items: ['one', 'two', 'three', 'four', 'five'],
- disableListWrap: false,
- disabledItemsFocusable: true,
- focusManagement: 'activeDescendant',
- isItemDisabled: (_, i) => i === 1,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('two');
- });
-
- it('should not move highlight when disabled wrap and match is before highlighted option', () => {
- const state: ListState = {
- highlightedValue: 'three',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.textNavigation,
- searchString: 'one',
- event: {} as React.KeyboardEvent,
- context: {
- items: ['one', 'two', 'three', 'four', 'five'],
- disableListWrap: true,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'single',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('three');
- });
- });
-
- describe('action: itemsChange', () => {
- const irrelevantConfig = {
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'activeDescendant' as const,
- isItemDisabled: () => false,
- getItemAsString: (option: any) => option,
- orientation: 'vertical' as const,
- pageSize: 5,
- selectionMode: 'single' as const,
- };
-
- describe('using default item comparer', () => {
- it('keeps the highlighted value if it is present among the new items', () => {
- const state: ListState = {
- highlightedValue: '1',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: ['1', '2'],
- previousItems: ['0', '1', '2'],
- context: {
- items: ['1', '2'],
- itemComparer: (o, v) => o === v,
- ...irrelevantConfig,
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('1');
- });
-
- it('resets the highlighted value if it is not present among the new items', () => {
- const state: ListState = {
- highlightedValue: '0',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: ['1', '2'],
- previousItems: ['0', '1', '2'],
- context: {
- items: ['1', '2'],
- itemComparer: (o, v) => o === v,
- ...irrelevantConfig,
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal(null);
- });
-
- it('keeps the selected values if they are present among the new items', () => {
- const state: ListState = {
- highlightedValue: '1',
- selectedValues: ['1', '2'],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: ['1', '2'],
- previousItems: ['0', '1', '2'],
- context: {
- items: ['1', '2'],
- itemComparer: (o, v) => o === v,
- ...irrelevantConfig,
- },
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['1', '2']);
- });
-
- it('removes the values from the selection if they are no longer present among the new items', () => {
- const state: ListState = {
- highlightedValue: '1',
- selectedValues: ['0', '2'],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: ['1', '2'],
- previousItems: ['0', '1', '2'],
- context: {
- items: ['1', '2'],
- itemComparer: (o, v) => o === v,
- ...irrelevantConfig,
- },
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal(['2']);
- });
- });
-
- describe('using custom item comparer', () => {
- type ItemType = { v: string };
-
- it('keeps the highlighted value if it is present among the new items', () => {
- const state: ListState = {
- highlightedValue: { v: '1' },
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: [{ v: '1' }, { v: '2' }],
- previousItems: [{ v: '0' }, { v: '1' }, { v: '2' }],
- context: {
- items: [{ v: '1' }, { v: '2' }],
- itemComparer: (a, b) => a.v === b.v,
- ...irrelevantConfig,
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue?.v).to.equal('1');
- });
-
- it('resets the highlighted value if it is not present among the new items', () => {
- const state: ListState = {
- highlightedValue: { v: '0' },
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: [{ v: '1' }, { v: '2' }],
- previousItems: [{ v: '0' }, { v: '1' }, { v: '2' }],
- context: {
- items: [{ v: '1' }, { v: '2' }],
- itemComparer: (a, b) => a.v === b.v,
- ...irrelevantConfig,
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal(null);
- });
-
- it('keeps the selected values if they are present among the new items', () => {
- const state: ListState = {
- highlightedValue: { v: '1' },
- selectedValues: [{ v: '1' }, { v: '2' }],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: [{ v: '1' }, { v: '2' }],
- previousItems: [{ v: '0' }, { v: '1' }, { v: '2' }],
- context: {
- items: [{ v: '1' }, { v: '2' }],
- itemComparer: (a, b) => a.v === b.v,
- ...irrelevantConfig,
- },
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues.map((sv) => sv.v)).to.deep.equal(['1', '2']);
- });
-
- it('removes the values from the selection if they are no longer present among the new items', () => {
- const state: ListState = {
- highlightedValue: { v: '1' },
- selectedValues: [{ v: '0' }, { v: '2' }],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: [{ v: '1' }, { v: '2' }],
- previousItems: [{ v: '0' }, { v: '1' }, { v: '2' }],
- context: {
- items: [{ v: '1' }, { v: '2' }],
- itemComparer: (a, b) => a.v === b.v,
- ...irrelevantConfig,
- },
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues.map((sv) => sv.v)).to.deep.equal(['2']);
- });
- });
-
- describe('after the items are initialized', () => {
- it('highlights the first item when using DOM focus management', () => {
- const state: ListState = {
- highlightedValue: null,
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: ['1', '2'],
- previousItems: [],
- context: {
- ...irrelevantConfig,
- items: ['1', '2'],
- itemComparer: (o, v) => o === v,
- focusManagement: 'DOM',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('1');
- });
-
- it('highlights the first enabled item when using DOM focus management', () => {
- const state: ListState = {
- highlightedValue: null,
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.itemsChange,
- event: null,
- items: ['1', '2', '3'],
- previousItems: [],
- context: {
- ...irrelevantConfig,
- items: ['1', '2', '3'],
- itemComparer: (o, v) => o === v,
- focusManagement: 'DOM',
- isItemDisabled: (item) => ['1', '2'].includes(item),
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('3');
- });
- });
- });
-
- describe('action: resetHighlight', () => {
- it('highlights the first item', () => {
- const state: ListState = {
- highlightedValue: 'three',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.resetHighlight,
- event: null,
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'DOM',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'none',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('one');
- });
-
- it('highlights the first non-disabled item', () => {
- const state: ListState = {
- highlightedValue: 'three',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.resetHighlight,
- event: null,
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'DOM',
- isItemDisabled: (item) => item === 'one',
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'none',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('two');
- });
- });
-
- describe('action: highlightLast', () => {
- it('highlights the last item', () => {
- const state: ListState = {
- highlightedValue: 'one',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.highlightLast,
- event: null,
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'DOM',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'none',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('three');
- });
-
- it('highlights the last non-disabled item', () => {
- const state: ListState = {
- highlightedValue: 'one',
- selectedValues: [],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.highlightLast,
- event: null,
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'DOM',
- isItemDisabled: (item) => item === 'three',
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'none',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.highlightedValue).to.equal('two');
- });
- });
-
- describe('action: clearSelection', () => {
- it('clears the selection', () => {
- const state: ListState = {
- highlightedValue: null,
- selectedValues: ['one', 'two'],
- };
-
- const action: ListReducerAction = {
- type: ListActionTypes.clearSelection,
- context: {
- items: ['one', 'two', 'three'],
- disableListWrap: false,
- disabledItemsFocusable: false,
- focusManagement: 'DOM',
- isItemDisabled: () => false,
- itemComparer: (o, v) => o === v,
- getItemAsString: (option) => option,
- orientation: 'vertical',
- pageSize: 5,
- selectionMode: 'none',
- },
- };
-
- const result = listReducer(state, action);
- expect(result.selectedValues).to.deep.equal([]);
- });
- });
-});
diff --git a/packages/react/src/useList/listReducer.ts b/packages/react/src/useList/listReducer.ts
deleted file mode 100644
index 975d692834..0000000000
--- a/packages/react/src/useList/listReducer.ts
+++ /dev/null
@@ -1,481 +0,0 @@
-import { ListActionTypes } from './listActions.types';
-import { ListState, ListReducerAction, ListActionContext, SelectionMode } from './useList.types';
-
-type ItemPredicate = (item: ItemValue, index: number) => boolean;
-
-/**
- * Looks up the next valid item to highlight within the list.
- *
- * @param currentIndex The index of the start of the search.
- * @param lookupDirection Whether to look for the next or previous item.
- * @param items The array of items to search.
- * @param includeDisabledItems Whether to include disabled items in the search.
- * @param isItemDisabled A function that determines whether an item is disabled.
- * @param wrapAround Whether to wrap around the list when searching.
- * @returns The index of the next valid item to highlight or -1 if no valid item is found.
- */
-function findValidItemToHighlight(
- currentIndex: number,
- lookupDirection: 'next' | 'previous',
- items: ItemValue[],
- includeDisabledItems: boolean,
- isItemDisabled: ItemPredicate,
- wrapAround: boolean,
-): number {
- if (
- items.length === 0 ||
- (!includeDisabledItems && items.every((item, itemIndex) => isItemDisabled(item, itemIndex)))
- ) {
- return -1;
- }
-
- let nextFocus = currentIndex;
-
- for (;;) {
- // No valid items found
- if (
- (!wrapAround && lookupDirection === 'next' && nextFocus === items.length) ||
- (!wrapAround && lookupDirection === 'previous' && nextFocus === -1)
- ) {
- return -1;
- }
-
- const nextFocusDisabled = includeDisabledItems
- ? false
- : isItemDisabled(items[nextFocus], nextFocus);
- if (nextFocusDisabled) {
- nextFocus += lookupDirection === 'next' ? 1 : -1;
- if (wrapAround) {
- nextFocus = (nextFocus + items.length) % items.length;
- }
- } else {
- return nextFocus;
- }
- }
-}
-
-/**
- * Gets the next item to highlight based on the current highlighted item and the search direction.
- *
- * @param previouslyHighlightedValue The item from which to start the search for the next candidate.
- * @param offset The offset from the previously highlighted item to search for the next candidate or a special named value ('reset', 'start', 'end').
- * @param context The list action context.
- *
- * @returns The next item to highlight or null if no item is valid.
- */
-export function moveHighlight(
- previouslyHighlightedValue: ItemValue | null,
- offset: number | 'reset' | 'start' | 'end',
- context: ListActionContext,
-) {
- const {
- items,
- isItemDisabled,
- disableListWrap,
- disabledItemsFocusable,
- itemComparer,
- focusManagement,
- } = context;
-
- // TODO: make this configurable
- // The always should be an item highlighted when focus is managed by the DOM
- // so that it's accessible by the `tab` key.
- const defaultHighlightedIndex = focusManagement === 'DOM' ? 0 : -1;
- const maxIndex = items.length - 1;
-
- const previouslyHighlightedIndex =
- previouslyHighlightedValue == null
- ? -1
- : items.findIndex((item) => itemComparer(item, previouslyHighlightedValue));
-
- let nextIndexCandidate: number;
- let lookupDirection: 'next' | 'previous';
- let wrapAround = !disableListWrap;
-
- switch (offset) {
- case 'reset':
- if (defaultHighlightedIndex === -1) {
- return null;
- }
-
- nextIndexCandidate = 0;
- lookupDirection = 'next';
- wrapAround = false;
- break;
-
- case 'start':
- nextIndexCandidate = 0;
- lookupDirection = 'next';
- wrapAround = false;
- break;
-
- case 'end':
- nextIndexCandidate = maxIndex;
- lookupDirection = 'previous';
- wrapAround = false;
- break;
-
- default: {
- const newIndex = previouslyHighlightedIndex + offset;
-
- if (newIndex < 0) {
- if ((!wrapAround && previouslyHighlightedIndex !== -1) || Math.abs(offset) > 1) {
- nextIndexCandidate = 0;
- lookupDirection = 'next';
- } else {
- nextIndexCandidate = maxIndex;
- lookupDirection = 'previous';
- }
- } else if (newIndex > maxIndex) {
- if (!wrapAround || Math.abs(offset) > 1) {
- nextIndexCandidate = maxIndex;
- lookupDirection = 'previous';
- } else {
- nextIndexCandidate = 0;
- lookupDirection = 'next';
- }
- } else {
- nextIndexCandidate = newIndex;
- lookupDirection = offset >= 0 ? 'next' : 'previous';
- }
- }
- }
-
- const nextIndex = findValidItemToHighlight(
- nextIndexCandidate,
- lookupDirection,
- items,
- disabledItemsFocusable,
- isItemDisabled,
- wrapAround,
- );
-
- // If there are no valid items to highlight, return the previously highlighted item (if it's still valid).
- if (
- nextIndex === -1 &&
- previouslyHighlightedValue !== null &&
- !isItemDisabled(previouslyHighlightedValue, previouslyHighlightedIndex)
- ) {
- return previouslyHighlightedValue;
- }
-
- return items[nextIndex] ?? null;
-}
-
-/**
- * Toggles the selection of an item.
- *
- * @param item Item to toggle.
- * @param selectedValues Already selected items.
- * @param selectionMode The number of items that can be simultanously selected.
- * @param itemComparer A custom item comparer function.
- *
- * @returns The new array of selected items.
- */
-export function toggleSelection(
- item: ItemValue,
- selectedValues: ItemValue[],
- selectionMode: SelectionMode,
- itemComparer: (item1: ItemValue, item2: ItemValue) => boolean,
-) {
- if (selectionMode === 'none') {
- return [];
- }
-
- if (selectionMode === 'single') {
- // if the item to select has already been selected, return the original array
- if (itemComparer(selectedValues[0], item)) {
- return selectedValues;
- }
-
- return [item];
- }
-
- // The toggled item is selected; remove it from the selection.
- if (selectedValues.some((sv) => itemComparer(sv, item))) {
- return selectedValues.filter((sv) => !itemComparer(sv, item));
- }
-
- // The toggled item is not selected - add it to the selection.
- return [...selectedValues, item];
-}
-
-/**
- * Handles item selection in a list.
- *
- * @param item - The item to be selected.
- * @param state - The current state of the list.
- * @param context - The context of the list action.
- * @returns The new state of the list after the item has been selected, or the original state if the item is disabled.
- */
-export function handleItemSelection>(
- item: ItemValue,
- state: State,
- context: ListActionContext,
-): State {
- const { itemComparer, isItemDisabled, selectionMode, items } = context;
- const { selectedValues } = state;
-
- const itemIndex = items.findIndex((i) => itemComparer(item, i));
-
- if (isItemDisabled(item, itemIndex)) {
- return state;
- }
-
- // if the item is already selected, remove it from the selection, otherwise add it
- const newSelectedValues = toggleSelection(item, selectedValues, selectionMode, itemComparer);
-
- return {
- ...state,
- selectedValues: newSelectedValues,
- highlightedValue: item,
- };
-}
-
-function handleKeyDown>(
- key: string,
- state: State,
- context: ListActionContext,
-): State {
- const previouslySelectedValue = state.highlightedValue;
- const { orientation, pageSize } = context;
-
- switch (key) {
- case 'Home':
- return {
- ...state,
- highlightedValue: moveHighlight(previouslySelectedValue, 'start', context),
- };
-
- case 'End':
- return {
- ...state,
- highlightedValue: moveHighlight(previouslySelectedValue, 'end', context),
- };
-
- case 'PageUp':
- return {
- ...state,
- highlightedValue: moveHighlight(previouslySelectedValue, -pageSize, context),
- };
-
- case 'PageDown':
- return {
- ...state,
- highlightedValue: moveHighlight(previouslySelectedValue, pageSize, context),
- };
-
- case 'ArrowUp':
- if (orientation !== 'vertical') {
- break;
- }
-
- return {
- ...state,
- highlightedValue: moveHighlight(previouslySelectedValue, -1, context),
- };
-
- case 'ArrowDown':
- if (orientation !== 'vertical') {
- break;
- }
-
- return {
- ...state,
- highlightedValue: moveHighlight(previouslySelectedValue, 1, context),
- };
-
- case 'ArrowLeft': {
- if (orientation === 'vertical') {
- break;
- }
-
- const offset = orientation === 'horizontal-ltr' ? -1 : 1;
-
- return {
- ...state,
- highlightedValue: moveHighlight(previouslySelectedValue, offset, context),
- };
- }
-
- case 'ArrowRight': {
- if (orientation === 'vertical') {
- break;
- }
-
- const offset = orientation === 'horizontal-ltr' ? 1 : -1;
-
- return {
- ...state,
- highlightedValue: moveHighlight(previouslySelectedValue, offset, context),
- };
- }
-
- case 'Enter':
- case ' ':
- if (state.highlightedValue === null) {
- return state;
- }
-
- return handleItemSelection(state.highlightedValue, state, context);
-
- default:
- break;
- }
-
- return state;
-}
-
-function handleBlur>(
- state: State,
- context: ListActionContext,
-): State {
- if (context.focusManagement === 'DOM') {
- return state;
- }
-
- return {
- ...state,
- highlightedValue: null,
- };
-}
-
-function textCriteriaMatches(
- nextFocus: ItemValue,
- searchString: string,
- stringifyItem: (item: ItemValue) => string | undefined,
-) {
- const text = stringifyItem(nextFocus)?.trim().toLowerCase();
-
- if (!text || text.length === 0) {
- // Make item not navigable if stringification fails or results in empty string.
- return false;
- }
-
- return text.indexOf(searchString) === 0;
-}
-
-function handleTextNavigation>(
- state: State,
- searchString: string,
- context: ListActionContext,
-): State {
- const { items, isItemDisabled, disabledItemsFocusable, getItemAsString } = context;
-
- const startWithCurrentItem = searchString.length > 1;
-
- let nextItem = startWithCurrentItem
- ? state.highlightedValue
- : moveHighlight(state.highlightedValue, 1, context);
-
- for (let index = 0; index < items.length; index += 1) {
- // Return un-mutated state if looped back to the currently highlighted value
- if (!nextItem || (!startWithCurrentItem && state.highlightedValue === nextItem)) {
- return state;
- }
-
- if (
- textCriteriaMatches(nextItem, searchString, getItemAsString) &&
- (!isItemDisabled(nextItem, items.indexOf(nextItem)) || disabledItemsFocusable)
- ) {
- // The nextItem is the element to be highlighted
- return {
- ...state,
- highlightedValue: nextItem,
- };
- }
- // Move to the next element.
- nextItem = moveHighlight(nextItem, 1, context);
- }
-
- // No item matches the text search criteria
- return state;
-}
-
-function handleItemsChange>(
- items: ItemValue[],
- previousItems: ItemValue[],
- state: State,
- context: ListActionContext,
-): State {
- const { itemComparer, focusManagement } = context;
-
- let newHighlightedValue: ItemValue | null = null;
-
- if (state.highlightedValue != null) {
- newHighlightedValue = items.find((item) => itemComparer(item, state.highlightedValue!)) ?? null;
- } else if (focusManagement === 'DOM' && previousItems.length === 0) {
- newHighlightedValue = moveHighlight(null, 'reset', context);
- }
-
- // exclude selected values that are no longer in the items list
- const selectedValues = state.selectedValues ?? [];
- const newSelectedValues = selectedValues.filter((selectedValue) =>
- items.some((item) => itemComparer(item, selectedValue)),
- );
-
- return {
- ...state,
- highlightedValue: newHighlightedValue,
- selectedValues: newSelectedValues,
- };
-}
-
-function handleResetHighlight>(
- state: State,
- context: ListActionContext,
-) {
- return {
- ...state,
- highlightedValue: moveHighlight(null, 'reset', context),
- };
-}
-
-function handleHighlightLast>(
- state: State,
- context: ListActionContext,
-) {
- return {
- ...state,
- highlightedValue: moveHighlight(null, 'end', context),
- };
-}
-
-function handleClearSelection>(
- state: State,
- context: ListActionContext