Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(DualListSelector next): Add next composable Dual List Selector #9901

Merged
merged 12 commits into from
Feb 27, 2024
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
Loading