-
Notifications
You must be signed in to change notification settings - Fork 377
feat(Select): add next version using menu components #8115
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
Changes from all commits
6f7e2f2
1be2c11
704d0d4
1fb42ae
1157e6d
d357fce
faaa32f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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( | ||
kmcfaul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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'; | ||
| 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}> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; | ||
| 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'; |
| 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; | ||
kmcfaul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /** Indicates the option is disabled */ | ||
| isDisabled?: boolean; | ||
| /** Indicates the option is selected */ | ||
| isSelected?: boolean; | ||
| /** Indicates the option is focused */ | ||
| isFocused?: boolean; | ||
kmcfaul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| export const SelectOption: React.FunctionComponent<MenuItemProps> = ({ | ||
| children, | ||
| className, | ||
| ...props | ||
kmcfaul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }: SelectOptionProps) => ( | ||
| <MenuItem className={css(className)} {...props}> | ||
| {children} | ||
| </MenuItem> | ||
| ); | ||
| SelectOption.displayName = 'SelectOption'; | ||
| 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 | ||
kmcfaul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| ### 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> | ||
| ); | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.