Skip to content

Commit

Permalink
feat(Select): add next version using menu components (#8115)
Browse files Browse the repository at this point in the history
* next(Select): add select using menu components

* add id

* fix duplicate id

* pr feedback

* add ouia props

* update ta data, update ta behavior to better match select

* remove example, update structure on grouped
  • Loading branch information
kmcfaul authored Oct 5, 2022
1 parent 04f5004 commit a2e72a1
Show file tree
Hide file tree
Showing 12 changed files with 818 additions and 1 deletion.
129 changes: 129 additions & 0 deletions packages/react-core/src/next/components/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React from 'react';
import { css } from '@patternfly/react-styles';
import { Menu, MenuContent, MenuProps } from '../../../components/Menu';
import { Popper } from '../../../helpers/Popper/Popper';
import { getOUIAProps, OUIAProps, getDefaultOUIAId } from '../../../helpers';

export interface SelectProps extends MenuProps, OUIAProps {
/** Anything which can be rendered in a select */
children?: React.ReactNode;
/** Classes applied to root element of select */
className?: string;
/** Flag to indicate if select is open */
isOpen?: boolean;
/** Single itemId for single select menus, or array of itemIds for multi select. You can also specify isSelected on the SelectOption. */
selected?: any | any[];
/** Renderer for a custom select toggle. Forwards a ref to the toggle. */
toggle: (toggleRef: React.RefObject<any>) => React.ReactNode;
/** Function callback when user selects an option. */
onSelect?: (event?: React.MouseEvent<Element, MouseEvent>, itemId?: string | number) => void;
/** Callback to allow the select component to change the open state of the menu.
* Triggered by clicking outside of the menu, or by pressing either tab or escape. */
onOpenChange?: (isOpen: boolean) => void;
/** Indicates if the select should be without the outer box-shadow */
isPlain?: boolean;
/** Minimum width of the select menu */
minWidth?: string;
/** @hide Forwarded ref */
innerRef?: React.Ref<HTMLDivElement>;
}

const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
children,
className,
onSelect,
isOpen,
selected,
toggle,
onOpenChange,
isPlain,
minWidth,
innerRef,
...props
}: SelectProps & OUIAProps) => {
const localMenuRef = React.useRef<HTMLDivElement>();
const toggleRef = React.useRef<HTMLButtonElement>();
const containerRef = React.useRef<HTMLDivElement>();

const menuRef = (innerRef as React.RefObject<HTMLDivElement>) || localMenuRef;
React.useEffect(() => {
const handleMenuKeys = (event: KeyboardEvent) => {
if (!isOpen && toggleRef.current?.contains(event.target as Node)) {
// toggle was clicked open, focus on first menu item
if (event.key === 'Enter' || event.key === 'Space') {
setTimeout(() => {
const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)');
firstElement && (firstElement as HTMLElement).focus();
}, 0);
}
}
// Close the menu on tab or escape if onOpenChange is provided
if (
(isOpen && onOpenChange && menuRef.current?.contains(event.target as Node)) ||
toggleRef.current?.contains(event.target as Node)
) {
if (event.key === 'Escape' || event.key === 'Tab') {
onOpenChange(!isOpen);
toggleRef.current?.focus();
}
}
};

const handleClickOutside = (event: MouseEvent) => {
// If the event is not on the toggle and onOpenChange callback is provided, close the menu
if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) {
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
onOpenChange(false);
}
}
};

window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);

return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClickOutside);
};
}, [isOpen, menuRef, onOpenChange]);

const menu = (
<Menu
className={css(className)}
ref={menuRef}
onSelect={(event, itemId) => onSelect(event, itemId)}
isPlain={isPlain}
selected={selected}
{...(minWidth && {
style: {
'--pf-c-menu--MinWidth': minWidth
} as React.CSSProperties
})}
{...getOUIAProps(
Select.displayName,
props.ouiaId !== undefined ? props.ouiaId : getDefaultOUIAId(Select.displayName),
props.ouiaSafe !== undefined ? props.ouiaSafe : true
)}
{...props}
>
<MenuContent>{children}</MenuContent>
</Menu>
);
return (
<div ref={containerRef}>
<Popper
trigger={toggle(toggleRef)}
removeFindDomNode
popper={menu}
appendTo={containerRef.current || undefined}
isVisible={isOpen}
/>
</div>
);
};

export const Select = React.forwardRef((props: SelectProps, ref: React.Ref<any>) => (
<SelectBase innerRef={ref} {...props} />
));

Select.displayName = 'Select';
24 changes: 24 additions & 0 deletions packages/react-core/src/next/components/Select/SelectGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { css } from '@patternfly/react-styles';
import { MenuGroupProps, MenuGroup } from '../../../components/Menu';

export interface SelectGroupProps extends Omit<MenuGroupProps, 'ref'> {
/** Anything which can be rendered in a select group */
children: React.ReactNode;
/** Classes applied to root element of select group */
className?: string;
/** Label of the select group */
label?: string;
}

export const SelectGroup: React.FunctionComponent<SelectGroupProps> = ({
children,
className,
label,
...props
}: SelectGroupProps) => (
<MenuGroup className={css(className)} label={label} {...props}>
{children}
</MenuGroup>
);
SelectGroup.displayName = 'SelectGroup';
21 changes: 21 additions & 0 deletions packages/react-core/src/next/components/Select/SelectList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import { css } from '@patternfly/react-styles';
import { MenuListProps, MenuList } from '../../../components/Menu';

export interface SelectListProps extends MenuListProps {
/** Anything which can be rendered in a select list */
children: React.ReactNode;
/** Classes applied to root element of select list */
className?: string;
}

export const SelectList: React.FunctionComponent<MenuListProps> = ({
children,
className,
...props
}: SelectListProps) => (
<MenuList className={css(className)} {...props}>
{children}
</MenuList>
);
SelectList.displayName = 'SelectList';
31 changes: 31 additions & 0 deletions packages/react-core/src/next/components/Select/SelectOption.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { css } from '@patternfly/react-styles';
import { MenuItemProps, MenuItem } from '../../../components/Menu';

export interface SelectOptionProps extends Omit<MenuItemProps, 'ref'> {
/** Anything which can be rendered in a select option */
children?: React.ReactNode;
/** Classes applied to root element of select option */
className?: string;
/** Identifies the component in the Select onSelect callback */
itemId?: any;
/** Indicates the option has a checkbox */
hasCheck?: boolean;
/** Indicates the option is disabled */
isDisabled?: boolean;
/** Indicates the option is selected */
isSelected?: boolean;
/** Indicates the option is focused */
isFocused?: boolean;
}

export const SelectOption: React.FunctionComponent<MenuItemProps> = ({
children,
className,
...props
}: SelectOptionProps) => (
<MenuItem className={css(className)} {...props}>
{children}
</MenuItem>
);
SelectOption.displayName = 'SelectOption';
37 changes: 37 additions & 0 deletions packages/react-core/src/next/components/Select/examples/Select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
id: Select
section: components
cssPrefix: pf-c-menu
propComponents: ['Select', SelectGroup, 'SelectOption', 'SelectList']
beta: true
ouia: true
---

import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';

## Examples

### Single

```ts file="./SelectBasic.tsx"
```

### Grouped single

```ts file="./SelectGrouped.tsx"
```

### Checkbox

```ts file="./SelectCheckbox.tsx"
```

### Typeahead

```ts file="./SelectTypeahead.tsx"
```

### Multiple Typeahead

```ts file="./SelectMultiTypeahead.tsx"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import { Select, SelectOption, SelectList } from '@patternfly/react-core/next';
import { MenuToggle, MenuToggleElement } from '@patternfly/react-core';

export const SelectBasic: React.FunctionComponent = () => {
const [isOpen, setIsOpen] = React.useState(false);
const [selected, setSelected] = React.useState<string>('Select a value');
const menuRef = React.useRef<HTMLDivElement>(null);

const onToggleClick = () => {
setIsOpen(!isOpen);
};

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, itemId: string | number | undefined) => {
// eslint-disable-next-line no-console
console.log('selected', itemId);

setSelected(itemId as string);
setIsOpen(false);
};

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
style={
{
width: '200px'
} as React.CSSProperties
}
>
{selected}
</MenuToggle>
);

return (
<Select
id="single-select"
ref={menuRef}
isOpen={isOpen}
selected={selected}
onSelect={onSelect}
onOpenChange={isOpen => setIsOpen(isOpen)}
toggle={toggle}
>
<SelectList>
<SelectOption itemId="Option 1">Option 1</SelectOption>
<SelectOption itemId="Option 2">Option 2</SelectOption>
<SelectOption itemId="Option 3">Option 3</SelectOption>
</SelectList>
</Select>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import { Select, SelectOption, SelectList } from '@patternfly/react-core/next';
import { MenuToggle, MenuToggleElement, Badge } from '@patternfly/react-core';

export const SelectCheckbox: React.FunctionComponent = () => {
const [isOpen, setIsOpen] = React.useState(false);
const [selectedItems, setSelectedItems] = React.useState<number[]>([]);
const menuRef = React.useRef<HTMLDivElement>(null);

const onToggleClick = () => {
setIsOpen(!isOpen);
};

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, itemId: string | number | undefined) => {
// eslint-disable-next-line no-console
console.log('selected', itemId);

if (selectedItems.includes(itemId as number)) {
setSelectedItems(selectedItems.filter(id => id !== itemId));
} else {
setSelectedItems([...selectedItems, itemId as number]);
}
};

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
style={
{
width: '200px'
} as React.CSSProperties
}
>
Filter by status
{selectedItems.length > 0 && <Badge isRead>{selectedItems.length}</Badge>}
</MenuToggle>
);

return (
<Select
id="checkbox-select"
ref={menuRef}
isOpen={isOpen}
selected={selectedItems}
onSelect={onSelect}
onOpenChange={(nextOpen: boolean) => setIsOpen(nextOpen)}
toggle={toggle}
>
<SelectList>
<SelectOption hasCheck itemId={0} isSelected={selectedItems.includes(0)}>
Debug
</SelectOption>
<SelectOption hasCheck itemId={1} isSelected={selectedItems.includes(1)}>
Info
</SelectOption>
<SelectOption hasCheck itemId={2} isSelected={selectedItems.includes(2)}>
Warn
</SelectOption>
<SelectOption hasCheck isDisabled itemId={4} isSelected={selectedItems.includes(4)}>
Error
</SelectOption>
</SelectList>
</Select>
);
};
Loading

0 comments on commit a2e72a1

Please sign in to comment.