import {useVirtualizer} from '@tanstack/react-virtual'; import React from 'react'; import {Generic, createModelHook, useUniqueId} from '@workday/canvas-kit-react/common'; export type Orientation = 'horizontal' | 'vertical'; export const defaultGetId = (item: Generic): string => { if (process.env.NODE_ENV === 'development') { if (typeof item === 'object' && item.id === undefined) { console.warn( "List item was an object, but no `getId` was passed to the model to inform the list where to find the item's identifier. Please pass a `getId` to the list model" ); } } return item === undefined ? '' : typeof item === 'string' ? item : item.id || ''; }; let hasWarnedNoGetTextValue = false; export const defaultGetTextValue = (item: Generic): string => { if (process.env.NODE_ENV === 'development') { if (!hasWarnedNoGetTextValue && typeof item === 'object' && item.text === undefined) { hasWarnedNoGetTextValue = true; console.warn( "List item was an object, but no `getTextValue` was passed to the model to inform the list where to find the item's text value. The item's text value is used for accessibility. Please pass a `getTextValue` to the list model" ); } } return typeof item === 'string' ? item : item === undefined ? '' : item.text || ''; }; export interface Item { index: number; id: string; value: T; /** * Used by components that allow jumping to an item based on typing */ textValue: string; } // interface Collection { // getKeys(): string[]; // getItem(id: string): Item | null; // at(index: number): Item | null; // size: number; // } // TODO: Build an interface that allows representation of different collections. For example an array and a dynamically loaded dataset // class StaticCollection implements Collection { // // eslint-disable-next-line no-empty-function // private items: Item[]; // constructor(items: T[], getId = defaultGetId, getTextValue = defaultGetTextValue) { // this.items = items.map((item, index) => ({ // index, // value: item, // id: getId(item), // textValue: getTextValue(item), // })); // } // getKeys() { // return this.items.map(item => item.id); // } // getItem(id: string) { // return this.items.find(item => item.id === id) || null; // } // at(index: number) { // return this.items[index] || null; // } // get size() { // return this.items.length; // } // } // force Typescript to use `Generic` as a symbol const genericDefaultConfig: { /** * Optional array of items. If provided, use a render prop for list children instead of static * children. If the shape of each item object does not have an `id` property or uses a different * property to uniquely identify each item, a `getId` must also be supplied. */ items: Generic[]; } = { items: [], }; export const useBaseListModel = createModelHook({ defaultConfig: { ...genericDefaultConfig, /** IDREF of the list. Children ids can be derived from this id */ id: '', /** * Optional function to return an id of an item. If not provided, the default function will * return the `id` property from the object of each item. If you did not provide `items`, do not * override this function. If you don't provided `items` and instead provide static items via * JSX, the list will create an internal array of items where `id` is the only property and the * default `getId` will return the desired result. */ getId: defaultGetId, /** * Optional function to return the text representation of an item. If not provided, the default * function will return the `text` property of the object of each item or an empty string if * there is no `text` property. If you did not provide `items`, do not override this function. */ getTextValue: defaultGetTextValue, /** * Array of all ids which are currently disabled. This is used for navigation to skip over items * which are not focusable. */ nonInteractiveIds: [] as string[], /** * The orientation of a list of items. Values are either `vertical` or `horizontal`. This value will * effect which ids activate progression through a list. For example, `horizontal` will activate with * left and right arrows while `vertical` will activate with up and down arrows. * @default 'vertical' */ orientation: 'vertical' as Orientation, /** * Best guess to the default item height for virtualization. Getting this number correct * avoids a rerender while the list is initializing. * * @default 50 */ defaultItemHeight: 50, shouldVirtualize: true, }, })(config => { const id = useUniqueId(config.id); // Optimization to not redo items when `getId` and `getTextValue` references change. They will not // likely change during the lifecycle and we don't want to recalculate items when a lamba is // passed instead of a stable reference. const getIdRef = React.useRef(defaultGetId); const getTextValueRef = React.useRef(defaultGetTextValue); getIdRef.current = config.getId || defaultGetId; getTextValueRef.current = config.getTextValue || config.getId || defaultGetTextValue; const [orientation] = React.useState(config.orientation || 'vertical'); const [UNSTABLE_defaultItemHeight, setDefaultItemHeight] = React.useState( config.defaultItemHeight ); const isVirtualized = config.shouldVirtualize && !!config.items?.length; const indexRef = React.useRef(0); const containerRef = React.useRef(null); const items: Item[] = React.useMemo( () => (config.items || []).map((item, index) => { return { id: getIdRef.current(item), index, value: item, textValue: getTextValueRef.current(item), }; }), [config.items] ); const [staticItems, setStaticItems] = React.useState[]>([]); const UNSTABLE_virtual = useVirtualizer({ count: items.length, getScrollElement: () => containerRef.current, estimateSize: React.useCallback(() => UNSTABLE_defaultItemHeight, [UNSTABLE_defaultItemHeight]), horizontal: config.orientation === 'horizontal', overscan: 3, // overscan of 3 helps rapid navigation }); // Force Typescript to recognize the `Generic` symbol const genericState: {items: Item[]} = { items: items.length ? items : staticItems, }; const state = { ...genericState, UNSTABLE_virtual, UNSTABLE_defaultItemHeight, containerRef, id, orientation, indexRef, nonInteractiveIds: config.nonInteractiveIds || [], isVirtualized, }; const events = { /** * Register an item to the list. Takes in an identifier, a React.Ref and an optional index. This * should be called on component mount. This event is only called for static rendering. */ registerItem(data: {id: string; textValue: string}) { indexRef.current++; setStaticItems(items => { return items.concat({ ...data, value: data, index: items.length, }); }); }, /** * Unregister an item by its identifier. This should be called when the component is unmounted. * This event is only called for static rendering. */ unregisterItem(data: {id: string}) { setStaticItems(items => { // this extra `if` ensures reference stability for no-ops if (items.find(item => item.id === data.id)) { return items.filter(item => item.id !== data.id); } else { return items; } }); }, /** * Updates the default item height. This should only be called when item height is measured. * Calling this with a different default item height than previous will cause a virtual list to * recalculate the overall height of the list and invalidate any height caching. Doing this only * on the first item may save the user from experiencing odd scrolling behavior where the * scrollbar updates while scrolling. If the user uses the mouse to drag the bar, it can become * "detached" since the browser recalculates scroll bar position while the overflow container is * updated. */ updateItemHeight(data: {value: number}) { setDefaultItemHeight(data.value); }, }; return {state, events, getId: config.getId, getTextValue: config.getTextValue}; });