src/js/components/List/List.js
import React, {
Fragment,
cloneElement,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import styled, { ThemeContext } from 'styled-components';
import { DataContext } from '../../contexts/DataContext';
import { Box } from '../Box';
import { Button } from '../Button';
import { InfiniteScroll } from '../InfiniteScroll';
import { Keyboard } from '../Keyboard';
import { Pagination } from '../Pagination';
import { Text } from '../Text';
import {
focusStyle,
genericStyles,
normalizeColor,
normalizeShow,
unfocusStyle,
useForwardedRef,
usePagination,
} from '../../utils';
import { useAnalytics } from '../../contexts/AnalyticsContext';
import { ListPropTypes } from './propTypes';
const emptyData = [];
const StyledList = styled.ul`
list-style: none;
${(props) => !props.margin && 'margin: 0;'}
padding: 0;
${genericStyles}
// Customizes to make list have a focus border color of green
&:focus {
${(props) =>
props.tabIndex >= 0 &&
focusStyle({ forceOutline: true, skipSvgChildren: true })}
}
${(props) => props.theme.list && props.theme.list.extend}}
&:focus:not(:focus-visible) {
${unfocusStyle()}
}
`;
const StyledItem = styled(Box)`
${(props) => props.onClick && !props.isDisabled && `cursor: pointer;`}
${(props) => props.draggable && !props.isDisabled && `cursor: move;`}
// during the interim state when a user is holding down a click,
// the individual list item has focus in the DOM until the click
// completes and focus is placed back on the list container.
// for visual consistency, we are showing focus on the list container
// as opposed to the item itself.
&:focus {
${unfocusStyle({ forceOutline: true, skipSvgChildren: true })}
}
${(props) => {
let disabledStyle;
if (props.isDisabled && props.theme.list?.item?.disabled) {
const { color, cursor } = props.theme.list.item.disabled;
disabledStyle = {
color: normalizeColor(color, props.theme),
cursor,
};
}
return disabledStyle;
}}
&:hover {
${(props) => props.isDisabled && `background-color: unset;`}
}
${(props) =>
props.theme.list && props.theme.list.item && props.theme.list.item.extend}
`;
// when paginated, this wraps the data table and pagination component
const StyledContainer = styled(Box)`
${(props) =>
props.theme.list &&
props.theme.list.container &&
props.theme.list.container.extend};
`;
const getValue = (item, index, key) => {
if (typeof key === 'function') return key(item, index);
if (typeof item === 'string') return item;
if (key !== undefined) return item?.[key];
return undefined;
};
const reorder = (array, pinnedArray, source, target) => {
const result = array.slice(0);
const tmp = result[source];
if (source < target)
for (let i = source; i < target; i += 1) result[i] = result[i + 1];
else for (let i = source; i > target; i -= 1) result[i] = result[i - 1];
result[target] = tmp;
// insert pinned items into their proper index within the orderable
// data object to make the complete data set again
if (pinnedArray.data.length > 0) {
pinnedArray.data.forEach((pinnedItem, index) => {
result.splice(pinnedArray.indexes[index], 0, pinnedItem);
});
}
return result;
};
// getItemId returns something appropriate to use as a unique DOM
// id for an item in the list
const getItemId = (item, index, itemKey, primaryKey) => {
// we do primaryKey first to be backward compatible, even though
// itemKey is probably technically the better choice for a DOM id.
if (primaryKey) return getValue(item, index, primaryKey);
if (itemKey) return getValue(item, index, itemKey);
return getValue(item, index) ?? index; // do our best w/o *key properties
};
const List = React.forwardRef(
(
{
a11yTitle,
'aria-label': ariaLabel,
action,
as,
background,
border,
children,
data: dataProp,
defaultItemProps,
disabled: disabledItems,
focus,
itemKey: defaultItemKey,
itemProps,
onActive,
onClickItem,
onKeyDown,
onMore,
onOrder,
pad,
paginate,
pinned = [],
primaryKey,
secondaryKey,
show: showProp,
step = paginate ? 50 : undefined,
...rest
},
ref,
) => {
const listRef = useForwardedRef(ref);
const theme = useContext(ThemeContext);
const { data: contextData } = useContext(DataContext);
const data = dataProp || contextData || emptyData;
// fixes issue where itemKey is undefined when only primaryKey is provided
const itemKey = defaultItemKey || primaryKey || null;
// active will be the index of the current 'active'
// control in the list. If the onOrder property is defined
// this will be the index of up or down control for ordering
// items in the list. In this case the item index of that
// control would be the active index / 2.
// If onOrder is not defined but onClickItem is (e.g. the
// List items are likely selectable), active will be the
// index of the item which is currently active.
const [active, setActive] = useState();
const [lastActive, setLastActive] = useState();
const updateActive = (nextActive) => {
setActive(nextActive);
// we occasionally call updateActive with undefined when it already is so,
// no need to call onActive in that case
if (onActive && onClickItem && nextActive !== active)
onActive(nextActive);
};
const [itemFocus, setItemFocus] = useState();
const [dragging, setDragging] = useState();
const [orderingData, setOrderingData] = useState();
// store a reference to the pinned and the data that is orderable
const [orderableData, pinnedInfo] = useMemo(() => {
const orderable = [];
const pinnedData = [];
const pinnedIndexes = [];
const currentData = orderingData || data;
if (pinned.length === 0)
return [currentData, { data: pinnedData, indexes: pinnedIndexes }];
currentData.forEach((item, index) => {
const key = getValue(item, index, itemKey);
const isPinned = Array.isArray(pinned)
? pinned.includes(key)
: typeof pinned === 'object' && pinned?.items?.includes(key);
if (isPinned) {
pinnedData.push(item);
pinnedIndexes.push(index);
} else {
orderable.push(item);
}
});
return [orderable, { data: pinnedData, indexes: pinnedIndexes }];
}, [data, orderingData, itemKey, pinned]);
const [items, paginationProps] = usePagination({
data,
page: normalizeShow(showProp, step),
step,
// let any specifications from paginate prop override component
...paginate,
});
const Container = paginate ? StyledContainer : Fragment;
const containterProps = paginate ? { ...theme.list.container } : undefined;
const draggingRef = useRef();
const sendAnalytics = useAnalytics();
const ariaProps = {
role: onClickItem || onOrder ? 'listbox' : 'list',
};
if (active >= 0) {
let activeId;
// We have an item that is 'focused' within the list. This could
// be the list item or one of the up/down ordering buttons.
// We need to figure out an id of the thing that will be shown as active
if (onOrder) {
// figure out which arrow button will be the active one.
const buttonId = active % 2 ? 'MoveDown' : 'MoveUp';
const itemIndex = Math.trunc(active / 2);
activeId = `${getItemId(
orderableData[itemIndex],
itemIndex,
itemKey,
primaryKey,
)}${buttonId}`;
} else if (onClickItem) {
// The whole list item is active. Figure out an id
activeId = getItemId(
orderableData[active],
active,
itemKey,
primaryKey,
);
}
ariaProps['aria-activedescendant'] = activeId;
}
const onSelectOption = (event) => {
if ((onClickItem || onOrder) && active >= 0) {
if (onOrder) {
const index = Math.trunc(active / 2);
// Call onOrder with the re-ordered data.
// Update the active control index so that the
// active control will stay on the same item
// even though it moved up or down.
const newIndex = active % 2 ? index + 1 : index - 1;
onOrder(reorder(orderableData, pinnedInfo, index, newIndex));
updateActive(
active % 2
? Math.min(active + 2, orderableData.length * 2 - 2)
: Math.max(active - 2, 1),
);
} else if (
disabledItems?.includes(getValue(data[active], active, itemKey))
) {
event.preventDefault();
} else if (onClickItem) {
event.persist();
const adjustedEvent = event;
adjustedEvent.item = data[active];
adjustedEvent.index = active;
onClickItem(adjustedEvent);
sendAnalytics({
type: 'listItemClick',
element: listRef.current,
event: adjustedEvent,
item: data[active],
index: active,
});
}
}
};
return (
<Container {...containterProps}>
<Keyboard
onEnter={onSelectOption}
onSpace={(event) => {
event.preventDefault();
onSelectOption(event);
}}
onUp={(event) => {
event.preventDefault();
if ((onClickItem || onOrder) && active) {
const min = onOrder ? 1 : 0;
updateActive(Math.max(active - 1, min));
}
}}
onDown={(event) => {
event.preventDefault();
if (
(onClickItem || onOrder) &&
orderableData &&
orderableData.length
) {
const min = onOrder ? 1 : 0;
const max = onOrder
? orderableData.length * 2 - 2
: data.length - 1;
updateActive(active >= min ? Math.min(active + 1, max) : min);
}
}}
onKeyDown={onKeyDown}
>
<StyledList
aria-label={ariaLabel || a11yTitle}
ref={listRef}
as={as || 'ul'}
itemFocus={itemFocus}
tabIndex={onClickItem || onOrder ? 0 : undefined}
onFocus={() =>
// Fixes zero-th index showing undefined.
// Checks for active variable to stop bug where activeStyle
// gets applied to lastActive instead of the item the user
// is currently clicking on
!active && active !== 0
? updateActive(lastActive)
: updateActive(active)
}
onBlur={() => {
setLastActive(active);
updateActive(undefined);
}}
{...ariaProps}
{...rest}
>
<InfiniteScroll
items={!paginate ? orderingData || data : items}
onMore={onMore}
show={!paginate ? showProp : undefined}
step={step}
renderMarker={(marker) => (
<Box as="li" flex={false}>
{marker}
</Box>
)}
>
{(item, index) => {
let content;
let boxProps = {};
const key = getValue(item, index, itemKey) || index;
let isPinned;
if (
(Array.isArray(pinned) && pinned.length > 0) ||
(Array.isArray(pinned?.items) && pinned?.items?.length > 0)
) {
if (typeof item === 'object' && !itemKey) {
console.error(
// eslint-disable-next-line max-len
`Warning: Missing prop itemKey. Prop pin requires itemKey to be specified when data is of type 'object'.`,
);
}
isPinned = Array.isArray(pinned)
? pinned?.includes(key)
: pinned.items.some((pinnedItem) => pinnedItem === key);
}
const pinnedColor = isPinned ? pinned.color : undefined;
if (children) {
content = children(
item,
index,
onClickItem ? { active: active === index } : undefined,
);
} else if (primaryKey) {
const primary = getValue(item, index, primaryKey);
content =
typeof primary === 'string' ||
typeof primary === 'number' ? (
<Text
color={pinnedColor}
key="p"
{...theme.list.primaryKey}
>
{primary}
</Text>
) : (
primary
);
if (secondaryKey) {
const secondary = getValue(item, index, secondaryKey);
content = [
content,
typeof secondary === 'string' ||
typeof secondary === 'number' ? (
<Text color={pinnedColor} key="s">
{getValue(item, index, secondaryKey)}
</Text>
) : (
secondary
),
];
boxProps = {
direction: 'row',
align: 'center',
justify: 'between',
gap: 'medium',
};
}
} else if (typeof item === 'object') {
const value = item[Object.keys(item)[0]];
content =
// for backwards compatibility, only wrap in Text if
// pinned.color is defined
pinnedColor && typeof value === 'string' ? (
<Text color={pinnedColor}>{value}</Text>
) : (
value
);
} else {
// for backwards compatibility, only wrap in Text if
// pinned.color is defined
content = pinnedColor ? (
<Text color={pinnedColor}>{item}</Text>
) : (
item
);
}
const orderableIndex = orderableData.findIndex(
(ordItem, ordIndex) =>
getValue(ordItem, ordIndex, itemKey) === key,
);
let isDisabled;
if (disabledItems) {
if (typeof item === 'object' && !itemKey) {
console.error(
// eslint-disable-next-line max-len
`Warning: Missing prop itemKey. Prop disabled requires itemKey to be specified when data is of type 'object'.`,
);
}
isDisabled = disabledItems?.includes(key);
}
if (action) {
content = [
<Box align="start" key={`actionContainer${index}`}>
{content}
</Box>,
action(item, index),
];
boxProps = {
direction: 'row',
align: secondaryKey ? 'start' : 'center',
justify: 'between',
gap: 'medium',
};
}
let adjustedBackground =
background || theme.list.item.background;
if ((!onOrder && active === index) || dragging === index) {
adjustedBackground = theme.global.hover.background;
} else if (Array.isArray(adjustedBackground)) {
adjustedBackground =
adjustedBackground[index % adjustedBackground.length];
} else if (isPinned) {
adjustedBackground =
pinned?.background || theme.list.item.pinned.background;
}
let adjustedBorder =
border !== undefined ? border : theme.list.item.border;
if (adjustedBorder === 'horizontal' && index) {
adjustedBorder = 'bottom';
}
let clickProps;
if (onClickItem && !onOrder) {
clickProps = {
role: 'option',
tabIndex: -1,
active: active === index,
onClick: (event) => {
// Only prevent event when disabled. We still want screen
// readers to be aware that an option exists, but is in a
// disabled state.
if (isDisabled) {
event.preventDefault();
} else {
// extract from React's synthetic event pool
event.persist();
const adjustedEvent = event;
adjustedEvent.item = item;
adjustedEvent.index = index;
onClickItem(adjustedEvent);
sendAnalytics({
type: 'listItemClick',
element: listRef.current,
event: adjustedEvent,
item,
index,
});
}
},
onMouseOver: () => updateActive(index),
onMouseOut: () => updateActive(undefined),
onFocus: () => {
updateActive(index);
setItemFocus(true);
},
onBlur: () => {
updateActive(undefined);
setItemFocus(false);
},
};
}
let orderProps;
let orderControls;
if (onOrder && !isPinned) {
orderProps = {
draggable: true,
onDragStart: (event) => {
event.dataTransfer.setData('text/plain', '');
// allowed per
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API#define_the_drag_effect
// eslint-disable-next-line no-param-reassign
event.dataTransfer.effectAllowed = 'move';
setDragging(orderableIndex);
updateActive(undefined);
},
onDragEnd: () => {
setDragging(undefined);
setOrderingData(undefined);
},
onDragOver: (event) => {
if (dragging !== undefined) {
event.preventDefault();
if (dragging !== orderableIndex) {
// eslint-disable-next-line no-param-reassign
event.dataTransfer.dropEffect = 'move';
setOrderingData(
reorder(
orderableData,
pinnedInfo,
dragging,
orderableIndex,
),
);
setDragging(orderableIndex);
}
}
},
onDrop: () => {
if (orderingData) {
onOrder(orderingData);
}
},
ref: dragging === orderableIndex ? draggingRef : undefined,
};
const Up = theme.list.icons.up;
const Down = theme.list.icons.down;
orderControls = !isPinned && (
<Box direction="row" align="center" justify="end">
<Button
id={`${key}MoveUp`}
a11yTitle={`${orderableIndex + 1} ${key} move up`}
icon={<Up />}
hoverIndicator
focusIndicator={false}
disabled={!orderableIndex}
active={active === orderableIndex * 2}
onClick={(event) => {
event.stopPropagation();
onOrder(
reorder(
orderableData,
pinnedInfo,
orderableIndex,
orderableIndex - 1,
),
);
}}
tabIndex={-1}
onMouseOver={() => updateActive(orderableIndex * 2)}
onMouseOut={() => updateActive(undefined)}
onFocus={() => {
updateActive(orderableIndex * 2);
setItemFocus(true);
}}
onBlur={() => {
updateActive(undefined);
setItemFocus(false);
}}
/>
<Button
id={`${key}MoveDown`}
a11yTitle={`${orderableIndex + 1} ${key} move down`}
icon={<Down />}
hoverIndicator
focusIndicator={false}
disabled={orderableIndex >= orderableData.length - 1}
active={active === orderableIndex * 2 + 1}
onClick={(event) => {
event.stopPropagation();
onOrder(
reorder(
orderableData,
pinnedInfo,
orderableIndex,
orderableIndex + 1,
),
);
}}
tabIndex={-1}
onMouseOver={() => updateActive(orderableIndex * 2 + 1)}
onMouseOut={() => updateActive(undefined)}
onFocus={() => {
updateActive(orderableIndex * 2 + 1);
setItemFocus(true);
}}
onBlur={() => {
updateActive(undefined);
setItemFocus(false);
}}
/>
</Box>
);
// wrap the main content and use
// the boxProps defined for the content
content = (
<Box flex {...boxProps}>
{content}
</Box>
);
// Adjust the boxProps to account for the order controls
boxProps = {
direction: 'row',
align:
(defaultItemProps && defaultItemProps.align) || 'center',
gap: 'medium',
};
}
let itemAriaProps;
if (isDisabled) {
itemAriaProps = {
'aria-disabled': true,
};
if (onClickItem) {
itemAriaProps = {
...itemAriaProps,
'aria-selected': false,
};
}
}
let displayPinned;
if (isPinned) {
const pinSize = theme.list.item.pinned.icon.size;
const pinPad = theme.list.item.pinned.icon.pad;
const Icon = pinned?.icon || theme.list.icons.pin;
let pinIcon = React.isValidElement(Icon) ? Icon : <Icon />;
pinIcon = cloneElement(pinIcon, {
// icon color prop should win over pinned.color
...(!pinIcon.props?.color && pinnedColor
? { color: pinnedColor }
: {}),
size: pinSize,
});
boxProps = {
direction: 'row',
align:
(defaultItemProps && defaultItemProps.align) || 'center',
gap: 'medium',
};
displayPinned = (
<Box
direction="row"
align="center"
justify="end"
pad={pinPad}
>
{pinIcon}
</Box>
);
content = <Box flex>{content}</Box>;
}
if (itemProps && itemProps[index]) {
boxProps = { ...boxProps, ...itemProps[index] };
}
return (
<StyledItem
key={key}
tag="li"
background={adjustedBackground}
border={adjustedBorder}
isDisabled={isDisabled}
flex={false}
pad={pad || theme.list.item.pad}
{...defaultItemProps}
{...boxProps}
{...clickProps}
{...orderProps}
{...itemAriaProps}
>
{onOrder && <Text color={pinnedColor}>{index + 1}</Text>}
{content}
{displayPinned}
{orderControls}
</StyledItem>
);
}}
</InfiniteScroll>
</StyledList>
</Keyboard>
{paginate && data.length > step && items && items.length ? (
<Pagination alignSelf="end" {...paginationProps} />
) : null}
</Container>
);
},
);
List.displayName = 'List';
List.propTypes = ListPropTypes;
export { List };