-
Notifications
You must be signed in to change notification settings - Fork 351
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(DualListSelector next): Add next composable Dual List Selector (#…
…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
1 parent
30a6d2d
commit 18c1ab3
Showing
22 changed files
with
2,681 additions
and
0 deletions.
There are no files selected for viewing
50 changes: 50 additions & 0 deletions
50
packages/react-core/src/next/components/DualListSelector/DualListSelector.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
21 changes: 21 additions & 0 deletions
21
packages/react-core/src/next/components/DualListSelector/DualListSelectorContext.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); |
66 changes: 66 additions & 0 deletions
66
packages/react-core/src/next/components/DualListSelector/DualListSelectorControl.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
87 changes: 87 additions & 0 deletions
87
packages/react-core/src/next/components/DualListSelector/DualListSelectorControlsWrapper.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
57 changes: 57 additions & 0 deletions
57
packages/react-core/src/next/components/DualListSelector/DualListSelectorList.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
105 changes: 105 additions & 0 deletions
105
packages/react-core/src/next/components/DualListSelector/DualListSelectorListItem.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.