Skip to content

Commit

Permalink
feat(DualListSelector next): Add next composable Dual List Selector (#…
Browse files Browse the repository at this point in the history
…9901)

* create next environment

* updated all examples to be composable

* fix double VO for list controls

* remove obsolete example files

* rename examples

* use stronger types for unknowns

* remove business logic in main component

* fix tests

* fixed a11y failures

* update composability based on PR review

* rm unnecessary funct and update tests

* rm prop null assignment and add beta tag
  • Loading branch information
mfrances17 committed Feb 27, 2024
1 parent 30a6d2d commit 18c1ab3
Show file tree
Hide file tree
Showing 22 changed files with 2,681 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import styles from '@patternfly/react-styles/css/components/DualListSelector/dual-list-selector';
import { css } from '@patternfly/react-styles';
import { GenerateId, PickOptional } from '../../../helpers';
import { DualListSelectorContext } from './DualListSelectorContext';

/** Acts as a container for all other DualListSelector sub-components when using a
* composable dual list selector.
*/

export interface DualListSelectorProps {
/** Additional classes applied to the dual list selector. */
className?: string;
/** ID of the dual list selector. */
id?: string;
/** Flag indicating if the dual list selector uses trees instead of simple lists. */
isTree?: boolean;
/** Content to be rendered in the dual list selector. */
children?: React.ReactNode;
}

class DualListSelector extends React.Component<DualListSelectorProps> {
static displayName = 'DualListSelector';
static defaultProps: PickOptional<DualListSelectorProps> = {
children: '',
isTree: false
};

constructor(props: DualListSelectorProps) {
super(props);
}

render() {
const { className, children, id, isTree, ...props } = this.props;

return (
<DualListSelectorContext.Provider value={{ isTree }}>
<GenerateId>
{(randomId) => (
<div className={css(styles.dualListSelector, className)} id={id || randomId} {...props}>
{children}
</div>
)}
</GenerateId>
</DualListSelectorContext.Provider>
);
}
}

export { DualListSelector };
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';

export const DualListSelectorContext = React.createContext<{
isTree?: boolean;
}>({ isTree: false });

export const DualListSelectorListContext = React.createContext<{
setFocusedOption?: (id: string) => void;
isTree?: boolean;
ariaLabelledBy?: string;
focusedOption?: string;
displayOption?: (option: React.ReactNode) => boolean;
selectedOptions?: string[] | number[];
id?: string;
options?: React.ReactNode[];
isDisabled?: boolean;
}>({});

export const DualListSelectorPaneContext = React.createContext<{
isChosen: boolean;
}>({ isChosen: false });
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as React from 'react';
import { css } from '@patternfly/react-styles';
import { Button, ButtonVariant } from '../../../components/Button';
import { Tooltip } from '../../../components/Tooltip';
import styles from '@patternfly/react-styles/css/components/DualListSelector/dual-list-selector';

/** Renders an individual control button for moving selected options between each
* dual list selector pane.
*/

export interface DualListSelectorControlProps extends Omit<React.HTMLProps<HTMLDivElement>, 'onClick'> {
/** Content to be rendered in the dual list selector control. */
children?: React.ReactNode;
/** @hide forwarded ref */
innerRef?: React.Ref<any>;
/** Flag indicating the control is disabled. */
isDisabled?: boolean;
/** Additional classes applied to the dual list selector control. */
className?: string;
/** Callback fired when dual list selector control is selected. */
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
/** Accessible label for the dual list selector control. */
'aria-label'?: string;
/** Content to be displayed in a tooltip on hover of control. */
tooltipContent?: React.ReactNode;
/** Additional tooltip properties passed to the tooltip. */
tooltipProps?: any;
}

export const DualListSelectorControlBase: React.FunctionComponent<DualListSelectorControlProps> = ({
innerRef,
children,
className,
'aria-label': ariaLabel,
isDisabled = true,
onClick = () => {},
tooltipContent,
tooltipProps = {} as any,
...props
}: DualListSelectorControlProps) => {
const privateRef = React.useRef(null);
const ref = innerRef || privateRef;
return (
<div className={css(styles.dualListSelectorControlsItem, className)} {...props}>
<Button
isDisabled={isDisabled}
aria-disabled={isDisabled}
variant={ButtonVariant.plain}
onClick={onClick}
aria-label={ariaLabel}
tabIndex={-1}
ref={ref}
>
{children}
</Button>
{tooltipContent && <Tooltip content={tooltipContent} position="left" triggerRef={ref} {...tooltipProps} />}
</div>
);
};
DualListSelectorControlBase.displayName = 'DualListSelectorControlBase';

export const DualListSelectorControl = React.forwardRef((props: DualListSelectorControlProps, ref: React.Ref<any>) => (
<DualListSelectorControlBase innerRef={ref} {...props} />
));

DualListSelectorControl.displayName = 'DualListSelectorControl';
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as React from 'react';
import styles from '@patternfly/react-styles/css/components/DualListSelector/dual-list-selector';
import { css } from '@patternfly/react-styles';
import { handleArrows } from '../../../helpers';

/** Acts as the container for the DualListSelectorControl sub-components. */

export interface DualListSelectorControlsWrapperProps extends React.HTMLProps<HTMLDivElement> {
/** Content to be rendered inside of the controls wrapper. */
children?: React.ReactNode;
/** Additional classes added to the wrapper. */
className?: string;
/** @hide Forwarded ref */
innerRef?: React.RefObject<HTMLDivElement>;
/** Accessible label for the dual list selector controls wrapper. */
'aria-label'?: string;
}

export const DualListSelectorControlsWrapperBase: React.FunctionComponent<DualListSelectorControlsWrapperProps> = ({
innerRef,
children = null,
className,
'aria-label': ariaLabel = 'Controls for moving options between lists',
...props
}: DualListSelectorControlsWrapperProps) => {
const ref = React.useRef(null);
const wrapperRef = innerRef || ref;
// Adds keyboard navigation to the dynamically built dual list selector controls. Works when controls are dynamically built
// as well as when they are passed in via children.
const handleKeys = (event: KeyboardEvent) => {
if (
!wrapperRef.current ||
(wrapperRef.current !== (event.target as HTMLElement).closest(`.${styles.dualListSelectorControls}`) &&
!Array.from(wrapperRef.current.getElementsByClassName(styles.dualListSelectorControls)).includes(
(event.target as HTMLElement).closest(`.${styles.dualListSelectorControls}`)
))
) {
return;
}
event.stopImmediatePropagation();

const controls = (Array.from(wrapperRef.current.getElementsByTagName('BUTTON')) as Element[]).filter(
(el) => !el.classList.contains('pf-m-disabled')
);
const activeElement = document.activeElement;
handleArrows(
event,
controls,
(element: Element) => activeElement.contains(element),
(element: Element) => element,
undefined,
undefined,
true,
false
);
};

React.useEffect(() => {
window.addEventListener('keydown', handleKeys);
return () => {
window.removeEventListener('keydown', handleKeys);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wrapperRef.current]);

return (
<div
className={css(styles.dualListSelectorControls, className)}
tabIndex={0}
ref={wrapperRef}
aria-label={ariaLabel}
{...props}
>
{children}
</div>
);
};

DualListSelectorControlsWrapperBase.displayName = 'DualListSelectorControlsWrapperBase';

export const DualListSelectorControlsWrapper = React.forwardRef(
(props: DualListSelectorControlsWrapperProps, ref: React.Ref<HTMLDivElement>) => (
<DualListSelectorControlsWrapperBase innerRef={ref as React.MutableRefObject<any>} role="group" {...props} />
)
);

DualListSelectorControlsWrapper.displayName = 'DualListSelectorControlsWrapper';
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/DualListSelector/dual-list-selector';
import { DualListSelectorListItem } from './DualListSelectorListItem';
import * as React from 'react';
import { DualListSelectorListContext } from './DualListSelectorContext';

/** Acts as the container for DualListSelectorListItem sub-components. */

export interface DualListSelectorListProps extends React.HTMLProps<HTMLUListElement> {
/** Content rendered inside the dual list selector list. */
children?: React.ReactNode;
}

export const DualListSelectorList: React.FunctionComponent<DualListSelectorListProps> = ({
children,
...props
}: DualListSelectorListProps) => {
const { isTree, ariaLabelledBy, focusedOption, displayOption, selectedOptions, id, options, isDisabled } =
React.useContext(DualListSelectorListContext);

const hasOptions = () =>
options.length !== 0 || (children !== undefined && (children as React.ReactNode[]).length !== 0);

return (
<ul
className={css(styles.dualListSelectorList)}
{...(hasOptions() && {
role: isTree ? 'tree' : 'listbox',
'aria-multiselectable': true,
'aria-labelledby': ariaLabelledBy,
'aria-activedescendant': focusedOption
})}
aria-disabled={isDisabled ? 'true' : undefined}
{...props}
>
{options.length === 0
? children
: options.map((option, index) => {
if (displayOption(option)) {
return (
<DualListSelectorListItem
key={index}
isSelected={(selectedOptions as number[]).indexOf(index) !== -1}
id={`${id}-option-${index}`}
orderIndex={index}
isDisabled={isDisabled}
>
{option}
</DualListSelectorListItem>
);
}
return;
})}
</ul>
);
};
DualListSelectorList.displayName = 'DualListSelectorList';
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as React from 'react';
import styles from '@patternfly/react-styles/css/components/DualListSelector/dual-list-selector';
import { css } from '@patternfly/react-styles';
import { getUniqueId } from '../../../helpers';
import GripVerticalIcon from '@patternfly/react-icons/dist/esm/icons/grip-vertical-icon';
import { Button, ButtonVariant } from '../../../components/Button';
import { DualListSelectorListContext } from './DualListSelectorContext';

/** Creates an individual option that can be selected and moved between the
* dual list selector panes. This is contained within the DualListSelectorList sub-component.
*/

export interface DualListSelectorListItemProps extends React.HTMLProps<HTMLLIElement> {
/** Content rendered inside the dual list selector. */
children?: React.ReactNode;
/** Additional classes applied to the dual list selector. */
className?: string;
/** Flag indicating the list item is currently selected. */
isSelected?: boolean;
/** Callback fired when an option is selected. */
onOptionSelect?: (event: React.MouseEvent | React.ChangeEvent | React.KeyboardEvent, id?: string) => void;
/** ID of the option. */
id?: string;
/** @hide Internal field used to keep track of order of unfiltered options. */
orderIndex?: number;
/** @hide Forwarded ref */
innerRef?: React.RefObject<HTMLLIElement>;
/** Flag indicating this item is draggable for reordering. */
isDraggable?: boolean;
/** Accessible label for the draggable button on draggable list items. */
draggableButtonAriaLabel?: string;
/** Flag indicating if the dual list selector is in a disabled state. */
isDisabled?: boolean;
}

export const DualListSelectorListItemBase: React.FunctionComponent<DualListSelectorListItemProps> = ({
onOptionSelect,
orderIndex,
children,
className,
id = getUniqueId('dual-list-selector-list-item'),
isSelected,
innerRef,
isDraggable = false,
isDisabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
draggableButtonAriaLabel = 'Reorder option',
...props
}: DualListSelectorListItemProps) => {
const privateRef = React.useRef<HTMLLIElement>(null);
const ref = innerRef || privateRef;
const { setFocusedOption } = React.useContext(DualListSelectorListContext);

return (
<li
className={css(styles.dualListSelectorListItem, className, isDisabled && styles.modifiers.disabled)}
key={orderIndex}
onClick={
isDisabled
? undefined
: (e: React.MouseEvent) => {
setFocusedOption(id);
onOptionSelect(e, id);
}
}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
(document.activeElement as HTMLElement).click();
e.preventDefault();
}
}}
aria-selected={isSelected}
id={id}
ref={ref}
role="option"
tabIndex={-1}
{...props}
>
<div className={css(styles.dualListSelectorListItemRow, isSelected && styles.modifiers.selected)}>
{isDraggable && !isDisabled && (
<div className={css(styles.dualListSelectorDraggable)}>
{/** TODO once keyboard accessibility is enabled, remove `component=span`
and add `aria-label={draggableButtonAriaLabel}` */}
<Button variant={ButtonVariant.plain} component="span">
<GripVerticalIcon style={{ verticalAlign: '-0.3em' }} />
</Button>
</div>
)}
<span className={css(styles.dualListSelectorItem)}>
<span className={css(styles.dualListSelectorItemMain)}>
<span className={css(styles.dualListSelectorItemText)}>{children}</span>
</span>
</span>
</div>
</li>
);
};
DualListSelectorListItemBase.displayName = 'DualListSelectorListItemBase';

export const DualListSelectorListItem = React.forwardRef(
(props: DualListSelectorListItemProps, ref: React.Ref<HTMLLIElement>) => (
<DualListSelectorListItemBase innerRef={ref as React.MutableRefObject<any>} {...props} />
)
);
DualListSelectorListItem.displayName = 'DualListSelectorListItem';
Loading

0 comments on commit 18c1ab3

Please sign in to comment.