Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way for the user to specify the heading level for the group title or other props for the group? Or is that level of customization a use case for building your own menu?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is possible, as all of these select components extend the menu components. In this case the labelHeadingLevel prop from MenuGroup would be used to set the heading level.

Perhaps the larger question is how we want to organize these extended props. I'm not a huge fan of duplicating the whole prop list into each Select component in case anything gets updated in Menu later on. Maybe in the docs I can pull in the Menu props as well as the Select props? @tlabaj

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying locally, it doesn't find the Menu components during tsDocgen because the next components are in a separate folder (so it's trying to parse the Menu props as menu-next files).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kmcfaul maybe we should open an issue in org so we can pull in props from src directory into next.
AS far as exposing props for Menu. I think if it is a common use case we can show it in the Select's prop table. If it is something we are not really encouraging/ is not a common use case I would vote to not add it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can open a quick follow up to go over the Menu props and see which ones we want to include for the dropdown/select wrappers & add them

{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