diff --git a/packages/@adobe/spectrum-css-temp/components/menu/index.css b/packages/@adobe/spectrum-css-temp/components/menu/index.css index 0acded71b46..71523a6a6ef 100644 --- a/packages/@adobe/spectrum-css-temp/components/menu/index.css +++ b/packages/@adobe/spectrum-css-temp/components/menu/index.css @@ -121,7 +121,8 @@ governing permissions and limitations under the License. .spectrum-Menu-checkmark { display: none; - align-self: flex-start; + align-self: center; + grid-area: checkmark; } .spectrum-Menu-divider { @@ -155,17 +156,18 @@ governing permissions and limitations under the License. .spectrum-Menu .spectrum-Menu { /* Fill parent menu when nested */ display: block; + margin: 0; } .spectrum-Menu-itemGrid { display: grid; - grid-template-columns: calc(var(--spectrum-selectlist-option-padding) - var(--spectrum-selectlist-border-size-key-focus)) auto 1fr auto auto var(--spectrum-selectlist-option-padding); + grid-template-columns: calc(var(--spectrum-selectlist-option-padding) - var(--spectrum-selectlist-border-size-key-focus)) auto 1fr auto auto auto var(--spectrum-selectlist-option-padding); grid-template-rows: var(--spectrum-selectlist-option-padding-y) 1fr auto var(--spectrum-selectlist-option-padding-y); grid-template-areas: - ". . . . . ." - ". icon text end keyboard ." - ". icon description end keyboard ." - ". . . . . ."; + ". . . . . . ." + ". icon text checkmark end keyboard ." + ". icon description checkmark end keyboard ." + ". . . . . . ."; } /* Added .spectrum-Menu so paddings from component styles are overriden */ @@ -173,8 +175,6 @@ governing permissions and limitations under the License. grid-area: end; justify-self: end; align-self: center; - margin-inline-end: 0px; - margin-inline-start: 0px; } .spectrum-Menu-icon { grid-area: icon; diff --git a/packages/@react-aria/button/src/useButtonGroup.ts b/packages/@react-aria/button/src/useButtonGroup.ts index 1ce317c627e..7d4d257ec79 100644 --- a/packages/@react-aria/button/src/useButtonGroup.ts +++ b/packages/@react-aria/button/src/useButtonGroup.ts @@ -49,7 +49,7 @@ export function useButtonGroup(props: ButtonGroupProps, state: ButtonGroupState) let {direction} = useLocale(); let keyboardDelegate = useMemo(() => new ButtonGroupKeyboardDelegate(state.buttonCollection, direction, orientation), [state.buttonCollection, direction, orientation]); - let {listProps} = useSelectableCollection({ + let {collectionProps} = useSelectableCollection({ selectionManager: state.selectionManager, keyboardDelegate }); @@ -68,7 +68,7 @@ export function useButtonGroup(props: ButtonGroupProps, state: ButtonGroupState) tabIndex: isDisabled ? null : tabIndex, 'aria-orientation': orientation, 'aria-disabled': isDisabled, - ...mergeProps(focusWithinProps, listProps) + ...mergeProps(focusWithinProps, collectionProps) }, buttonProps: { role: BUTTON_ROLES[selectionMode] diff --git a/packages/@react-aria/collections/src/useCollectionItem.ts b/packages/@react-aria/collections/src/useCollectionItem.ts index b6e18640583..1775ece38d0 100644 --- a/packages/@react-aria/collections/src/useCollectionItem.ts +++ b/packages/@react-aria/collections/src/useCollectionItem.ts @@ -1,3 +1,15 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + import {CollectionManager, LayoutInfo, Size} from '@react-stately/collections'; import {RefObject, useCallback, useLayoutEffect} from 'react'; diff --git a/packages/@react-spectrum/menu/src/MenuDivider.tsx b/packages/@react-aria/listbox/index.ts similarity index 62% rename from packages/@react-spectrum/menu/src/MenuDivider.tsx rename to packages/@react-aria/listbox/index.ts index eee46dee3f9..bbd9b8c2c84 100644 --- a/packages/@react-spectrum/menu/src/MenuDivider.tsx +++ b/packages/@react-aria/listbox/index.ts @@ -3,25 +3,11 @@ * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {classNames} from '@react-spectrum/utils'; -import {Divider} from '@react-spectrum/divider'; -import React from 'react'; -import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; - -export function MenuDivider() { - return ( - - ); -} +export * from './src'; diff --git a/packages/@react-aria/listbox/package.json b/packages/@react-aria/listbox/package.json new file mode 100644 index 00000000000..3a4d4bc0a8b --- /dev/null +++ b/packages/@react-aria/listbox/package.json @@ -0,0 +1,30 @@ +{ + "name": "@react-aria/listbox", + "version": "3.0.0-alpha.1", + "private": true, + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe-private/react-spectrum-v3" + }, + "dependencies": { + "@babel/runtime": "^7.6.2", + "@react-aria/interactions": "^3.0.0-rc.1", + "@react-aria/selection": "^3.0.0-alpha.1", + "@react-aria/utils": "^3.0.0-rc.1", + "@react-stately/list": "^3.0.0-alpha.1" + }, + "peerDependencies": { + "react": "^16.8.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-spectrum/menu/src/MenuHeading.tsx b/packages/@react-aria/listbox/src/index.ts similarity index 52% rename from packages/@react-spectrum/menu/src/MenuHeading.tsx rename to packages/@react-aria/listbox/src/index.ts index 7b33393bbfe..8629bde916b 100644 --- a/packages/@react-spectrum/menu/src/MenuHeading.tsx +++ b/packages/@react-aria/listbox/src/index.ts @@ -3,31 +3,13 @@ * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {classNames} from '@react-spectrum/utils'; -import {Heading} from '@react-spectrum/typography'; -import React from 'react'; -import {SpectrumMenuHeadingProps} from '@react-types/menu'; -import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; - -export function MenuHeading({item}: SpectrumMenuHeadingProps) { - return ( - - {item.rendered} - - ); -} +export * from './useListBox'; +export * from './useOption'; +export * from './useListBoxSection'; diff --git a/packages/@react-aria/listbox/src/useListBox.ts b/packages/@react-aria/listbox/src/useListBox.ts new file mode 100644 index 00000000000..c9b6b4392c6 --- /dev/null +++ b/packages/@react-aria/listbox/src/useListBox.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {HTMLAttributes} from 'react'; +import {ListState} from '@react-stately/list'; +import {useSelectableList} from '@react-aria/selection'; + +interface ListBoxAria { + listBoxProps: HTMLAttributes +} + +export function useListBox(props, state: ListState): ListBoxAria { + let {listProps} = useSelectableList({ + ...props, + selectionManager: state.selectionManager, + collection: state.collection + }); + + return { + listBoxProps: { + role: 'listbox', + 'aria-multiselectable': state.selectionManager.selectionMode === 'multiple' ? 'true' : undefined, + ...listProps + } + }; +} diff --git a/packages/@react-aria/listbox/src/useListBoxSection.ts b/packages/@react-aria/listbox/src/useListBoxSection.ts new file mode 100644 index 00000000000..220ffa8b78e --- /dev/null +++ b/packages/@react-aria/listbox/src/useListBoxSection.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {HTMLAttributes} from 'react'; +import {useId} from '@react-aria/utils'; + +interface ListBoxSectionAria { + itemProps: HTMLAttributes, + headingProps: HTMLAttributes, + groupProps: HTMLAttributes +} + +export function useListBoxSection(): ListBoxSectionAria { + let headingId = useId(); + + return { + itemProps: { + role: 'presentation' + }, + headingProps: { + // Techincally, listbox cannot contain headings according to ARIA. + // We hide the heading from assistive technology, and only use it + // as a label for the nested group. + id: headingId, + 'aria-hidden': true + }, + groupProps: { + role: 'group', + 'aria-labelledby': headingId + } + }; +} diff --git a/packages/@react-aria/listbox/src/useOption.ts b/packages/@react-aria/listbox/src/useOption.ts new file mode 100644 index 00000000000..78b55c91cf7 --- /dev/null +++ b/packages/@react-aria/listbox/src/useOption.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {HTMLAttributes, Key, RefObject} from 'react'; +import {ListState} from '@react-stately/list'; +import {usePress} from '@react-aria/interactions'; +import {useSelectableItem} from '@react-aria/selection'; + +interface OptionProps { + isDisabled?: boolean, + isSelected?: boolean, + key?: Key, + ref?: RefObject, + isVirtualized?: boolean +} + +interface OptionAria { + optionProps: HTMLAttributes +} + +export function useOption(props: OptionProps, state: ListState): OptionAria { + let { + isSelected, + isDisabled, + key, + ref, + isVirtualized + } = props; + + let optionProps = { + role: 'option', + 'aria-disabled': isDisabled, + 'aria-selected': isSelected + }; + + if (isVirtualized) { + optionProps['aria-posinset'] = state.collection.getItem(key).index; + optionProps['aria-setsize'] = state.collection.size; + } + + let {itemProps} = useSelectableItem({ + selectionManager: state.selectionManager, + itemKey: key, + itemRef: ref, + isVirtualized + }); + + let {pressProps} = usePress({...itemProps, isDisabled}); + + return { + optionProps: { + ...optionProps, + ...pressProps + } + }; +} diff --git a/packages/@react-aria/menu/src/index.ts b/packages/@react-aria/menu/src/index.ts index a730b8fa6b3..e2c770eff80 100644 --- a/packages/@react-aria/menu/src/index.ts +++ b/packages/@react-aria/menu/src/index.ts @@ -13,3 +13,4 @@ export * from './useMenuTrigger'; export * from './useMenu'; export * from './useMenuItem'; +export * from './useMenuSection'; diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index 2a56c804403..77dda7f0806 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -10,62 +10,35 @@ * governing permissions and limitations under the License. */ -import {AllHTMLAttributes} from 'react'; -import {ListLayout} from '@react-stately/collections'; +import {AllHTMLAttributes, RefObject} from 'react'; +import {KeyboardDelegate} from '@react-types/shared'; import {MenuProps} from '@react-types/menu'; -import {Orientation} from '@react-types/shared'; import {TreeState} from '@react-stately/tree'; -import {useId} from '@react-aria/utils'; -import {useSelectableCollection} from '@react-aria/selection'; +import {useSelectableList} from '@react-aria/selection'; interface MenuAria { - menuProps: AllHTMLAttributes, - menuItemProps: AllHTMLAttributes + menuProps: AllHTMLAttributes } interface MenuState extends TreeState {} -interface MenuLayout extends ListLayout {} - -export function useMenu(props: MenuProps, state: MenuState, layout: MenuLayout): MenuAria { - let { - 'aria-orientation': ariaOrientation = 'vertical' as Orientation, - role = 'menu', - id, - selectionMode, - autoFocus, - wrapAround, - focusStrategy - } = props; - - let menuId = useId(id); +interface AriaMenuProps extends MenuProps { + ref?: RefObject, + isVirtualized?: boolean, + keyboardDelegate?: KeyboardDelegate +} - let {listProps} = useSelectableCollection({ +export function useMenu(props: AriaMenuProps, state: MenuState): MenuAria { + let {listProps} = useSelectableList({ + ...props, selectionManager: state.selectionManager, - keyboardDelegate: layout, - autoFocus, - focusStrategy, - wrapAround + collection: state.collection }); - let menuItemRole = 'menuitem'; - if (role === 'listbox') { - menuItemRole = 'option'; - } else if (selectionMode === 'single') { - menuItemRole = 'menuitemradio'; - } else if (selectionMode === 'multiple') { - menuItemRole = 'menuitemcheckbox'; - } - return { menuProps: { - ...listProps, - id: menuId, - role, - 'aria-orientation': ariaOrientation - }, - menuItemProps: { - role: menuItemRole + role: 'menu', + ...listProps } }; } diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index d153bef58ce..67b879a1f84 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -10,9 +10,8 @@ * governing permissions and limitations under the License. */ -import {AllHTMLAttributes, RefObject} from 'react'; -import {MenuItemProps} from '@react-types/menu'; -import {mergeProps, useId} from '@react-aria/utils'; +import {AllHTMLAttributes, Key, RefObject} from 'react'; +import {mergeProps} from '@react-aria/utils'; import {TreeState} from '@react-stately/tree'; import {usePress} from '@react-aria/interactions'; import {useSelectableItem} from '@react-aria/selection'; @@ -23,61 +22,77 @@ interface MenuItemAria { interface MenuState extends TreeState {} -export function useMenuItem(props: MenuItemProps, ref: RefObject, state: MenuState, onClose?: () => void, closeOnSelect?: boolean): MenuItemAria { +interface MenuItemProps { + isDisabled?: boolean, + isSelected?: boolean, + key?: Key, + ref?: RefObject, + onClose?: () => void, + closeOnSelect?: boolean, + isVirtualized?: boolean +} + +export function useMenuItem(props: MenuItemProps, state: MenuState): MenuItemAria { let { isSelected, isDisabled, key, - role = 'menuitem' + onClose, + closeOnSelect, + ref, + isVirtualized } = props; - let {itemProps} = useSelectableItem({ - selectionManager: state.selectionManager, - itemKey: key, - itemRef: ref - }); + let role = 'menuitem'; + if (state.selectionManager.selectionMode === 'single') { + role = 'menuitemradio'; + } else if (state.selectionManager.selectionMode === 'multiple') { + role = 'menuitemcheckbox'; + } let ariaProps = { 'aria-disabled': isDisabled, - id: useId(), role }; - if (role === 'option') { - ariaProps['aria-selected'] = isSelected ? 'true' : 'false'; - } else if (role === 'menuitemradio' || role === 'menuitemcheckbox') { - ariaProps['aria-checked'] = isSelected ? 'true' : 'false'; + if (state.selectionManager.selectionMode !== 'none') { + ariaProps['aria-checked'] = isSelected; + } + + if (isVirtualized) { + ariaProps['aria-posinset'] = state.collection.getItem(key).index; + ariaProps['aria-setsize'] = state.collection.size; } let onKeyDown = (e) => { switch (e.key) { case ' ': - if (!isDisabled) { - if (role === 'menuitem') { - if (closeOnSelect) { - onClose && onClose(); - } - } + if (!isDisabled && state.selectionManager.selectionMode === 'none' && closeOnSelect && onClose) { + onClose(); } break; case 'Enter': - if (!isDisabled) { - onClose && onClose(); + if (!isDisabled && onClose) { + onClose(); } break; } - }; + }; let onPress = (e) => { - if (e.pointerType !== 'keyboard') { - if (closeOnSelect) { - onClose && onClose(); - } + if (e.pointerType !== 'keyboard' && closeOnSelect && onClose) { + onClose(); } }; - let onMouseOver = () => state.selectionManager.setFocusedKey(key); + let {itemProps} = useSelectableItem({ + selectionManager: state.selectionManager, + itemKey: key, + itemRef: ref + }); + let {pressProps} = usePress(mergeProps({onPress, onKeyDown, isDisabled}, itemProps)); + let onMouseOver = () => state.selectionManager.setFocusedKey(key); return { menuItemProps: { diff --git a/packages/@react-aria/menu/src/useMenuSection.ts b/packages/@react-aria/menu/src/useMenuSection.ts new file mode 100644 index 00000000000..820b0955df6 --- /dev/null +++ b/packages/@react-aria/menu/src/useMenuSection.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {HTMLAttributes} from 'react'; +import {useId} from '@react-aria/utils'; + +interface MenuSectionAria { + itemProps: HTMLAttributes, + headingProps: HTMLAttributes, + groupProps: HTMLAttributes +} + +export function useMenuSection(): MenuSectionAria { + let headingId = useId(); + + return { + itemProps: { + role: 'presentation' + }, + headingProps: { + // Techincally, menus cannot contain headings according to ARIA. + // We hide the heading from assistive technology, and only use it + // as a label for the nested group. + id: headingId, + 'aria-hidden': true + }, + groupProps: { + role: 'group', + 'aria-labelledby': headingId + } + }; +} diff --git a/packages/@react-aria/menu/test/useMenu.test.js b/packages/@react-aria/menu/test/useMenu.test.js deleted file mode 100644 index ea733ac6ad8..00000000000 --- a/packages/@react-aria/menu/test/useMenu.test.js +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {ListLayout} from '@react-stately/collections'; -import React from 'react'; -import {renderHook} from 'react-hooks-testing-library'; -import {useMenu} from '../'; - -describe('useMenu', function () { - let mockState = {}; - let mockLayout = new ListLayout({ - rowHeight: 32, - headingHeight: 26 - }); - - let renderMenuHook = (menuProps) => { - let {result} = renderHook(() => useMenu(menuProps, mockState, mockLayout)); - return result.current; - }; - - it('should return default props for a menu', function () { - let {menuProps, menuItemProps} = renderMenuHook({}); - expect(menuProps['aria-orientation']).toBe('vertical'); - expect(menuProps.role).toBe('menu'); - expect(menuProps.id).toBeTruthy(); - expect(menuProps.onKeyDown).toBeTruthy(); - expect(menuProps.onFocus).toBeTruthy(); - expect(menuProps.onBlur).toBeTruthy(); - expect(menuItemProps.role).toBe('menuitem'); - }); - - it('should accommodate user defined aria attributes', function () { - let props = { - role: 'menubar', - 'aria-orientation': 'horizontal', - id: 'blah' - }; - - let {menuProps} = renderMenuHook(props); - expect(menuProps['aria-orientation']).toBe('horizontal'); - expect(menuProps.role).toBe('menubar'); - expect(menuProps.id).toBe('blah'); - expect(menuProps.onKeyDown).toBeTruthy(); - expect(menuProps.onFocus).toBeTruthy(); - expect(menuProps.onBlur).toBeTruthy(); - }); - - it('should return an appropriate menu item role based on the menu role or selection mode', function () { - let props = renderMenuHook({role: 'listbox'}); - expect(props.menuItemProps.role).toBe('option'); - props = renderMenuHook({selectionMode: 'single'}); - expect(props.menuItemProps.role).toBe('menuitemradio'); - props = renderMenuHook({selectionMode: 'multiple'}); - expect(props.menuItemProps.role).toBe('menuitemcheckbox'); - }); -}); diff --git a/packages/@react-aria/menu/test/useMenuItem.test.js b/packages/@react-aria/menu/test/useMenuItem.test.js deleted file mode 100644 index a5e61b74da8..00000000000 --- a/packages/@react-aria/menu/test/useMenuItem.test.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {renderHook} from 'react-hooks-testing-library'; -import {useMenuItem} from '../'; - -describe('useMenuItem', function () { - let setFocusedKey = jest.fn(); - let mockState = { - selectionManager: { - setFocusedKey - } - }; - let onClose = jest.fn(); - - let renderMenuHook = (menuItemProps, closeOnSelect) => { - let ref = { - current: { - focus: jest.fn() - } - }; - let {result} = renderHook(() => useMenuItem(menuItemProps, ref, mockState, onClose, closeOnSelect)); - return result.current; - }; - // All interactions (keydown, press interactions) and aria props are tested within Menu.test.js - - it('should return default props for a menuitem', function () { - let {menuItemProps} = renderMenuHook({}); - expect(menuItemProps['aria-disabled']).toBeUndefined(); - expect(menuItemProps.role).toBe('menuitem'); - expect(menuItemProps.id).toBeTruthy(); - expect(menuItemProps.tabIndex).toBe(0); - expect(menuItemProps.onKeyDown).toBeTruthy(); - expect(menuItemProps.onKeyUp).toBeTruthy(); - expect(menuItemProps.onFocus).toBeTruthy(); - expect(menuItemProps.onMouseDown).toBeTruthy(); - expect(menuItemProps.onMouseOver).toBeTruthy(); - expect(menuItemProps.onMouseEnter).toBeTruthy(); - expect(menuItemProps.onMouseLeave).toBeTruthy(); - expect(menuItemProps.onTouchStart).toBeTruthy(); - expect(menuItemProps.onTouchMove).toBeTruthy(); - expect(menuItemProps.onTouchEnd).toBeTruthy(); - expect(menuItemProps.onTouchCancel).toBeTruthy(); - expect(menuItemProps.onClick).toBeTruthy(); - }); - - it('should accommodate user defined aria attributes', function () { - let props = { - role: 'option' - }; - let {menuItemProps} = renderMenuHook(props); - expect(menuItemProps.role).toBe('option'); - }); - - it('should call setFocusedKey if item is moused over', function () { - let props = { - key: 'testkey' - }; - let {menuItemProps} = renderMenuHook(props); - menuItemProps.onMouseOver(); - expect(setFocusedKey).toHaveBeenCalledWith(props.key); - }); -}); diff --git a/packages/@react-aria/selection/package.json b/packages/@react-aria/selection/package.json index 6890f9c57d7..e61804388c8 100644 --- a/packages/@react-aria/selection/package.json +++ b/packages/@react-aria/selection/package.json @@ -19,6 +19,7 @@ "dependencies": { "@babel/runtime": "^7.6.2", "@react-aria/interactions": "^3.0.0-alpha.2", + "@react-stately/collections": "^3.0.0-alpha.2", "@react-stately/selection": "^3.0.0-alpha.2", "@react-types/shared": "^3.0.0-rc.1" }, diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts new file mode 100644 index 00000000000..de8eafc766a --- /dev/null +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Collection, Node} from '@react-stately/collections'; +import {Key, RefObject} from 'react'; +import {KeyboardDelegate} from '@react-types/shared'; + +export class ListKeyboardDelegate implements KeyboardDelegate { + private collection: Collection>; + private ref: RefObject; + + constructor(collection: Collection>, ref: RefObject) { + this.collection = collection; + this.ref = ref; + } + + getKeyBelow(key: Key) { + key = this.collection.getKeyAfter(key); + while (key) { + let item = this.collection.getItem(key); + if (item.type === 'item' && !item.isDisabled) { + return key; + } + + key = this.collection.getKeyAfter(key); + } + } + + getKeyAbove(key: Key) { + key = this.collection.getKeyBefore(key); + while (key) { + let item = this.collection.getItem(key); + if (item.type === 'item' && !item.isDisabled) { + return key; + } + + key = this.collection.getKeyBefore(key); + } + } + + getFirstKey() { + let key = this.collection.getFirstKey(); + while (key) { + let item = this.collection.getItem(key); + if (item.type === 'item' && !item.isDisabled) { + return key; + } + + key = this.collection.getKeyAfter(key); + } + } + + getLastKey() { + let key = this.collection.getLastKey(); + while (key) { + let item = this.collection.getItem(key); + if (item.type === 'item' && !item.isDisabled) { + return key; + } + + key = this.collection.getKeyBefore(key); + } + } + + private getItem(key: Key): HTMLElement { + return this.ref.current.querySelector(`[data-key="${key}"]`); + } + + getKeyPageAbove(key: Key) { + let menu = this.ref.current; + let item = this.getItem(key); + if (!item) { + return null; + } + + let pageY = Math.max(0, item.offsetTop + item.offsetHeight - menu.offsetHeight); + + while (item && item.offsetTop > pageY) { + key = this.getKeyAbove(key); + item = this.getItem(key); + } + + return key; + } + + getKeyPageBelow(key: Key) { + let menu = this.ref.current; + let item = this.getItem(key); + if (!item) { + return null; + } + + let pageY = Math.min(menu.scrollHeight, item.offsetTop - item.offsetHeight + menu.offsetHeight); + + while (item && item.offsetTop < pageY) { + key = this.getKeyBelow(key); + item = this.getItem(key); + } + + return key; + } +} diff --git a/packages/@react-aria/selection/src/index.ts b/packages/@react-aria/selection/src/index.ts index 52e036af33b..ff18cc9e85a 100644 --- a/packages/@react-aria/selection/src/index.ts +++ b/packages/@react-aria/selection/src/index.ts @@ -12,3 +12,5 @@ export * from './useSelectableCollection'; export * from './useSelectableItem'; +export * from './useSelectableList'; +export * from './ListKeyboardDelegate'; diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index f04f750e83e..8b1b0431ab5 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -29,19 +29,19 @@ function isCtrlKeyPressed(e: KeyboardEvent) { return e.ctrlKey; } -interface SelectableListOptions { +interface SelectableCollectionOptions { selectionManager: MultipleSelectionManager, keyboardDelegate: KeyboardDelegate, autoFocus?: boolean, focusStrategy?: FocusStrategy, - wrapAround?: boolean + wrapAround?: boolean } -interface SelectableListAria { - listProps: HTMLAttributes +interface SelectableCollectionAria { + collectionProps: HTMLAttributes } -export function useSelectableCollection(options: SelectableListOptions): SelectableListAria { +export function useSelectableCollection(options: SelectableCollectionOptions): SelectableCollectionAria { let { selectionManager: manager, keyboardDelegate: delegate, @@ -205,10 +205,11 @@ export function useSelectableCollection(options: SelectableListOptions): Selecta manager.setFocusedKey(focusedKey); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { - listProps: { + collectionProps: { onKeyDown, onFocus, onBlur diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index b6c2f2341fc..2b782e3d18e 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -18,7 +18,8 @@ import {PressProps} from '@react-aria/interactions'; interface SelectableItemOptions { selectionManager: MultipleSelectionManager, itemKey: Key, - itemRef: RefObject + itemRef: RefObject, + isVirtualized?: boolean } interface SelectableItemAria { @@ -29,7 +30,8 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte let { selectionManager: manager, itemKey, - itemRef + itemRef, + isVirtualized } = options; let onPressStart = (e: PressEvent) => { @@ -58,13 +60,19 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } }, [itemRef, isFocused, manager.focusedKey, manager.isFocused]); - return { - itemProps: { - onPressStart, - tabIndex: isFocused ? 0 : -1, - onFocus() { - manager.setFocusedKey(itemKey); - } + let itemProps = { + onPressStart, + tabIndex: isFocused ? 0 : -1, + onFocus() { + manager.setFocusedKey(itemKey); } }; + + if (!isVirtualized) { + itemProps['data-key'] = itemKey; + } + + return { + itemProps + }; } diff --git a/packages/@react-aria/selection/src/useSelectableList.ts b/packages/@react-aria/selection/src/useSelectableList.ts new file mode 100644 index 00000000000..c6e4826e037 --- /dev/null +++ b/packages/@react-aria/selection/src/useSelectableList.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Collection, Node} from '@react-stately/collections'; +import {FocusStrategy} from '@react-types/menu'; +import {HTMLAttributes, RefObject, useEffect, useMemo} from 'react'; +import {KeyboardDelegate} from '@react-types/shared'; +import {ListKeyboardDelegate} from './ListKeyboardDelegate'; +import {MultipleSelectionManager} from '@react-stately/selection'; +import {useSelectableCollection} from './useSelectableCollection'; + +interface SelectableListOptions { + selectionManager: MultipleSelectionManager, + collection: Collection>, + ref?: RefObject, + keyboardDelegate?: KeyboardDelegate, + autoFocus?: boolean, + focusStrategy?: FocusStrategy, + wrapAround?: boolean, + isVirtualized?: boolean +} + +interface SelectableListAria { + listProps: HTMLAttributes +} + +export function useSelectableList(props: SelectableListOptions): SelectableListAria { + let { + selectionManager, + collection, + ref, + keyboardDelegate, + autoFocus, + focusStrategy, + wrapAround, + isVirtualized + } = props; + + // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). + // When virtualized, the layout object will be passed in as a prop and override this. + let delegate = useMemo(() => keyboardDelegate || new ListKeyboardDelegate(collection, ref), [keyboardDelegate, collection, ref]); + + // If not virtualized, scroll the focused element into view when the focusedKey changes. + // When virtualized, CollectionView handles this internally. + useEffect(() => { + if (!isVirtualized && selectionManager.focusedKey) { + let element = ref.current.querySelector(`[data-key="${selectionManager.focusedKey}"]`); + if (element) { + element.scrollIntoView({block: 'nearest'}); + } + } + }, [isVirtualized, ref, selectionManager.focusedKey]); + + let {collectionProps} = useSelectableCollection({ + selectionManager, + keyboardDelegate: delegate, + autoFocus, + focusStrategy, + wrapAround + }); + + return { + listProps: collectionProps + }; +} diff --git a/packages/@react-aria/sidenav/src/useSideNav.ts b/packages/@react-aria/sidenav/src/useSideNav.ts index 42087145a2a..945c847e7de 100644 --- a/packages/@react-aria/sidenav/src/useSideNav.ts +++ b/packages/@react-aria/sidenav/src/useSideNav.ts @@ -33,7 +33,7 @@ export function useSideNav(props: SideNavAriaProps, state: TreeState, l id = useId(id); - let {listProps} = useSelectableCollection({ + let {collectionProps} = useSelectableCollection({ selectionManager: state.selectionManager, keyboardDelegate: layout }); @@ -48,7 +48,7 @@ export function useSideNav(props: SideNavAriaProps, state: TreeState, l listProps: { 'aria-labelledby': ariaLabeldBy || (ariaLabel ? id : null), role: 'list', - ...listProps + ...collectionProps } }; } diff --git a/packages/@react-spectrum/listbox/index.ts b/packages/@react-spectrum/listbox/index.ts new file mode 100644 index 00000000000..bbd9b8c2c84 --- /dev/null +++ b/packages/@react-spectrum/listbox/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-spectrum/listbox/package.json b/packages/@react-spectrum/listbox/package.json new file mode 100644 index 00000000000..939f5de58d7 --- /dev/null +++ b/packages/@react-spectrum/listbox/package.json @@ -0,0 +1,50 @@ +{ + "name": "@react-spectrum/listbox", + "version": "3.0.0-alpha.1", + "private": true, + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "targets": { + "main": { + "includeNodeModules": ["@adobe/spectrum-css-temp"] + }, + "module": { + "includeNodeModules": ["@adobe/spectrum-css-temp"] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/adobe-private/react-spectrum-v3" + }, + "dependencies": { + "@babel/runtime": "^7.6.2", + "@react-aria/collections": "^3.0.0-alpha.1", + "@react-aria/focus": "^3.0.0-rc.1", + "@react-aria/listbox": "^3.0.0-alpha.1", + "@react-aria/separator": "^3.0.0-rc.1", + "@react-aria/utils": "^3.0.0-alpha.1", + "@react-spectrum/layout": "^3.0.0-alpha.1", + "@react-spectrum/provider": "^3.0.0-alpha.1", + "@react-spectrum/typography": "^3.0.0-alpha.1", + "@react-spectrum/utils": "^3.0.0-alpha.1", + "@react-stately/collections": "^3.0.0-alpha.1", + "@react-stately/list": "^3.0.0-alpha.1", + "@spectrum-icons/ui": "^3.0.0-rc.1" + }, + "devDependencies": { + "@adobe/spectrum-css-temp": "^3.0.0-alpha.1" + }, + "peerDependencies": { + "react": "^16.8.0", + "react-dom": "^16.8.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-spectrum/listbox/src/ListBox.tsx b/packages/@react-spectrum/listbox/src/ListBox.tsx new file mode 100644 index 00000000000..5a6ae02c45a --- /dev/null +++ b/packages/@react-spectrum/listbox/src/ListBox.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {classNames, filterDOMProps, useStyleProps} from '@react-spectrum/utils'; +import {CollectionView} from '@react-aria/collections'; +import {ListBoxOption} from './ListBoxOption'; +import {ListBoxSection} from './ListBoxSection'; +import {ListLayout} from '@react-stately/collections'; +import React, {useMemo} from 'react'; +import {SpectrumMenuProps} from '@react-types/menu'; +import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; +import {useListBox} from '@react-aria/listbox'; +import {useListState} from '@react-stately/list'; +import {useProvider} from '@react-spectrum/provider'; + +export function ListBox(props: SpectrumMenuProps) { + let {scale} = useProvider(); + let layout = useMemo(() => + new ListLayout({ + estimatedRowHeight: scale === 'large' ? 48 : 32, + estimatedHeadingHeight: scale === 'large' ? 31 : 25 + }) + , [scale]); + + let completeProps = { + ...props, + selectionMode: props.selectionMode || 'single' + }; + + let state = useListState(completeProps); + let {listBoxProps} = useListBox({...completeProps, keyboardDelegate: layout, isVirtualized: true}, state); + let {styleProps} = useStyleProps(completeProps); + + return ( + + {(type, item) => { + if (type === 'section') { + return ( + + ); + } + + return ( + + ); + }} + + ); +} diff --git a/packages/@react-spectrum/listbox/src/ListBoxOption.tsx b/packages/@react-spectrum/listbox/src/ListBoxOption.tsx new file mode 100644 index 00000000000..f1513c746c7 --- /dev/null +++ b/packages/@react-spectrum/listbox/src/ListBoxOption.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import CheckmarkMedium from '@spectrum-icons/ui/CheckmarkMedium'; +import {classNames} from '@react-spectrum/utils'; +import {FocusRing} from '@react-aria/focus'; +import {Grid} from '@react-spectrum/layout'; +import {ListState} from '@react-stately/list'; +import {Node} from '@react-stately/collections'; +import React from 'react'; +import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; +import {Text} from '@react-spectrum/typography'; +import {useOption} from '@react-aria/listbox'; +import {useRef} from 'react'; + +interface OptionProps { + item: Node, + state: ListState +} + +export function ListBoxOption(props: OptionProps) { + let { + item, + state + } = props; + + let { + rendered, + isSelected, + isDisabled, + key + } = item; + + let ref = useRef(); + let {optionProps} = useOption( + { + isSelected, + isDisabled, + key, + ref, + isVirtualized: true + }, + state + ); + + return ( + +
  • + + {!Array.isArray(rendered) && ( + + {rendered} + + )} + {Array.isArray(rendered) && rendered} + {isSelected && + + } + +
  • +
    + ); +} diff --git a/packages/@react-spectrum/listbox/src/ListBoxSection.tsx b/packages/@react-spectrum/listbox/src/ListBoxSection.tsx new file mode 100644 index 00000000000..1220d911224 --- /dev/null +++ b/packages/@react-spectrum/listbox/src/ListBoxSection.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {classNames} from '@react-spectrum/utils'; +import {ListState} from '@react-stately/list'; +import {Node} from '@react-stately/collections'; +import React, {Fragment, ReactNode} from 'react'; +import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; +import {useListBoxSection} from '@react-aria/listbox'; +import {useSeparator} from '@react-aria/separator'; + +interface ListBoxSectionProps { + item: Node, + state: ListState, + children?: ReactNode +} + +export function ListBoxSection(props: ListBoxSectionProps) { + let {item, state, children} = props; + let {itemProps, headingProps, groupProps} = useListBoxSection(); + let {separatorProps} = useSeparator({ + elementType: 'li' + }); + + return ( + + {item.key !== state.collection.getFirstKey() && +
  • + } +
  • + {item.rendered && + + {item.rendered} + + } +
      + {children} +
    +
  • +
    + ); +} diff --git a/packages/@react-spectrum/listbox/src/index.ts b/packages/@react-spectrum/listbox/src/index.ts new file mode 100644 index 00000000000..26f90948432 --- /dev/null +++ b/packages/@react-spectrum/listbox/src/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './ListBox'; +export {Item, Section} from '@react-stately/collections'; diff --git a/packages/@react-spectrum/listbox/stories/ListBox.stories.tsx b/packages/@react-spectrum/listbox/stories/ListBox.stories.tsx new file mode 100644 index 00000000000..d3e7bcf7d49 --- /dev/null +++ b/packages/@react-spectrum/listbox/stories/ListBox.stories.tsx @@ -0,0 +1,474 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {action} from '@storybook/addon-actions'; +import AlignCenter from '@spectrum-icons/workflow/AlignCenter'; +import AlignLeft from '@spectrum-icons/workflow/AlignLeft'; +import AlignRight from '@spectrum-icons/workflow/AlignRight'; +import Blower from '@spectrum-icons/workflow/Blower'; +import Book from '@spectrum-icons/workflow/Book'; +import ChevronRightMedium from '@spectrum-icons/ui/ChevronRightMedium'; +import Copy from '@spectrum-icons/workflow/Copy'; +import Cut from '@spectrum-icons/workflow/Cut'; +import {Item, ListBox, Section} from '../'; +import {Keyboard, Text} from '@react-spectrum/typography'; +import Paste from '@spectrum-icons/workflow/Paste'; +import {Popover} from '@react-spectrum/overlays'; +import React from 'react'; +import {storiesOf} from '@storybook/react'; + +let iconMap = { + AlignCenter, + AlignLeft, + AlignRight, + Blower, + Book, + Copy, + Cut, + Paste +}; + +let hardModeProgrammatic = [ + {name: 'Section 1', children: [ + {name: 'Copy', icon: 'Copy', shortcut: '⌘C'}, + {name: 'Cut', icon: 'Cut', shortcut: '⌘X'}, + {name: 'Paste', icon: 'Paste', shortcut: '⌘V'} + ]}, + {name: 'Section 2', children: [ + {name: 'Puppy', icon: 'AlignLeft', shortcut: '⌘P'}, + {name: 'Doggo', icon: 'AlignCenter', shortcut: '⌘D'}, + {name: 'Floof', icon: 'AlignRight', shortcut: '⌘F'}, + {name: 'hasChildren', children: [ + {name: 'Thailand', icon: 'Blower', shortcut: '⌘T'}, + {name: 'Germany', icon: 'Book', shortcut: '⌘G'} + ]} + ]} +]; + +let flatOptions = [ + {name: 'Aardvark'}, + {name: 'Kangaroo'}, + {name: 'Snake'}, + {name: 'Danni'}, + {name: 'Devon'}, + {name: 'Ross'}, + {name: 'Puppy'}, + {name: 'Doggo'}, + {name: 'Floof'} +]; + +let withSection = [ + {name: 'Animals', children: [ + {name: 'Aardvark'}, + {name: 'Kangaroo'}, + {name: 'Snake'} + ]}, + {name: 'People', children: [ + {name: 'Danni'}, + {name: 'Devon'}, + {name: 'Ross', children: [ + {name: 'Tests'} + ]} + ]} +]; + +storiesOf('ListBox', module) + .add( + 'Default ListBox', + () => ( + + + {item => {item.name}} + + + ) + ) + .add( + 'ListBox w/ sections', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'Static', + () => ( + + + One + Two + Three + + + ) + ) + .add( + 'Static with sections', + () => ( + + +
    + One + Two + Three +
    +
    + One + Two + Three +
    +
    +
    + ) + ) + .add( + 'with default selected options', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'static with default selected options', + () => ( + + +
    + + One + + + Two + + + Three + +
    +
    + + Four + + + Five + + + Six + + + Seven + +
    +
    +
    + ) + ) + .add( + 'with selected options (controlled)', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'static with selected options (controlled)', + () => ( + + +
    + + One + + + Two + + + Three + +
    +
    + + Four + + + Five + + + Six + + + Seven + +
    +
    +
    + ) + ) + .add( + 'with disabled options', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'static with disabled options', + () => ( + + +
    + + One + + + Two + + + Three + +
    +
    + + Four + + + Five + + + Six + + + Seven + +
    +
    +
    + ) + ) + .add( + 'Multiple selection', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'Multiple selection, static', + () => ( + + +
    + + One + + + Two + + + Three + +
    +
    + + Four + + + Five + + + Six + +
    +
    +
    + ) + ) + .add( + 'No selection allowed', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'No selection allowed, static', + () => ( + + +
    + One + Two + Three +
    +
    + Four + Five + Six +
    +
    +
    + ) + ) + .add( + 'ListBox with autoFocus=true', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'ListBox with autoFocus=true and focusStrategy="last"', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'ListBox with keyboard selection wrapping', + () => ( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ) + ) + .add( + 'with semantic elements (static)', + () => ( + + +
    + + + Copy + ⌘C + + + + Cut + ⌘X + + + + Paste + ⌘V + +
    +
    + + + Puppy + Puppy description super long as well geez + + + + Doggo with really really really long long long text + Value + + + + + Floof + + + Basic Item + +
    +
    +
    + ) + ) + .add( + 'with semantic elements (generative)', + () => ( + + + {item => ( +
    + {item => customOption(item)} +
    + )} +
    +
    + ) + ); + +let customOption = (item) => { + let Icon = iconMap[item.icon]; + return ( + + {item.icon && } + {item.name} + {item.shortcut && {item.shortcut}} + + ); +}; diff --git a/packages/@react-spectrum/listbox/test/ListBox.test.js b/packages/@react-spectrum/listbox/test/ListBox.test.js new file mode 100644 index 00000000000..109eafba182 --- /dev/null +++ b/packages/@react-spectrum/listbox/test/ListBox.test.js @@ -0,0 +1,482 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {cleanup, fireEvent, render, waitForDomChange, within} from '@testing-library/react'; +import {Item, ListBox, Section} from '../'; +import {Provider} from '@react-spectrum/provider'; +import React from 'react'; +import scaleMedium from '@adobe/spectrum-css-temp/vars/spectrum-medium-unique.css'; +import themeLight from '@adobe/spectrum-css-temp/vars/spectrum-light-unique.css'; +import {triggerPress} from '@react-spectrum/test-utils'; + +let theme = { + light: themeLight, + medium: scaleMedium +}; + +let withSection = [ + {name: 'Heading 1', children: [ + {name: 'Foo'}, + {name: 'Bar'}, + {name: 'Baz'} + ]}, + {name: 'Heading 2', children: [ + {name: 'Blah'}, + {name: 'Bleh'} + ]} +]; + +function renderComponent(props) { + return render( + + + {item => ( +
    + {item => {item.name}} +
    + )} +
    +
    + ); +} + +describe('ListBox', function () { + let offsetWidth, offsetHeight; + let onSelectionChange = jest.fn(); + + beforeAll(function () { + offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 1000); + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 1000); + }); + + afterEach(() => { + onSelectionChange.mockClear(); + cleanup(); + }); + + afterAll(function () { + offsetWidth.mockReset(); + offsetHeight.mockReset(); + }); + + it('renders properly', async function () { + let tree = renderComponent(); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + expect(listbox).toBeTruthy(); + + let sections = within(listbox).getAllByRole('group'); + expect(sections.length).toBe(2); + + for (let section of sections) { + expect(section).toHaveAttribute('aria-labelledby'); + let heading = document.getElementById(section.getAttribute('aria-labelledby')); + expect(heading).toBeTruthy(); + expect(heading).toHaveAttribute('aria-hidden', 'true'); + } + + let dividers = within(listbox).getAllByRole('separator'); + expect(dividers.length).toBe(1); + + let items = within(listbox).getAllByRole('option'); + expect(items.length).toBe(5); + let i = 0; + for (let item of items) { + expect(item).toHaveAttribute('tabindex'); + expect(item).toHaveAttribute('aria-selected'); + expect(item).toHaveAttribute('aria-disabled'); + expect(item).toHaveAttribute('aria-posinset', '' + i++); + expect(item).toHaveAttribute('aria-setsize'); + } + let item1 = within(listbox).getByText('Foo'); + let item2 = within(listbox).getByText('Bar'); + let item3 = within(listbox).getByText('Baz'); + let item4 = within(listbox).getByText('Blah'); + let item5 = within(listbox).getByText('Bleh'); + + expect(item1).toBeTruthy(); + expect(item2).toBeTruthy(); + expect(item3).toBeTruthy(); + expect(item4).toBeTruthy(); + expect(item5).toBeTruthy(); + expect(item3).toBeTruthy(); + }); + + it('allows user to change menu item focus via up/down arrow keys', function () { + let tree = renderComponent({autoFocus: true}); + let listbox = tree.getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + let selectedItem = options[0]; + expect(document.activeElement).toBe(selectedItem); + fireEvent.keyDown(selectedItem, {key: 'ArrowDown', code: 40, charCode: 40}); + let nextSelectedItem = options[1]; + expect(document.activeElement).toBe(nextSelectedItem); + fireEvent.keyDown(nextSelectedItem, {key: 'ArrowUp', code: 38, charCode: 38}); + expect(document.activeElement).toBe(selectedItem); + }); + + it('wraps focus from first to last/last to first item if up/down arrow is pressed if wrapAround is true', function () { + let tree = renderComponent({autoFocus: true, wrapAround: true}); + let listbox = tree.getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + let firstItem = options[0]; + expect(document.activeElement).toBe(firstItem); + fireEvent.keyDown(firstItem, {key: 'ArrowUp', code: 38, charCode: 38}); + let lastItem = options[options.length - 1]; + expect(document.activeElement).toBe(lastItem); + fireEvent.keyDown(lastItem, {key: 'ArrowDown', code: 40, charCode: 40}); + expect(document.activeElement).toBe(firstItem); + }); + + describe('supports single selection', function () { + it('supports defaultSelectedKeys (uncontrolled)', function () { + // Check that correct menu item is selected by default + let tree = renderComponent({onSelectionChange, defaultSelectedKeys: ['Blah'], autoFocus: true}); + let listbox = tree.getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + let selectedItem = options[3]; + expect(selectedItem).toBe(document.activeElement); + expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + expect(selectedItem).toHaveAttribute('tabindex', '0'); + let itemText = within(selectedItem).getByText('Blah'); + expect(itemText).toBeTruthy(); + let checkmark = within(selectedItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Select a different menu item via enter + let nextSelectedItem = options[4]; + fireEvent.keyDown(nextSelectedItem, {key: 'Enter', code: 13, charCode: 13}); + expect(nextSelectedItem).toHaveAttribute('aria-selected', 'true'); + itemText = within(nextSelectedItem).getByText('Bleh'); + expect(itemText).toBeTruthy(); + checkmark = within(nextSelectedItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Make sure there is only a single checkmark in the entire menu + let checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(1); + + expect(onSelectionChange).toBeCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0].has('Bleh')).toBeTruthy(); + }); + + it('supports selectedKeys (controlled)', function () { + // Check that correct menu item is selected by default + let tree = renderComponent({onSelectionChange, selectedKeys: ['Blah'], autoFocus: true}); + let listbox = tree.getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + let selectedItem = options[3]; + expect(selectedItem).toBe(document.activeElement); + expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + expect(selectedItem).toHaveAttribute('tabindex', '0'); + let itemText = within(selectedItem).getByText('Blah'); + expect(itemText).toBeTruthy(); + let checkmark = within(selectedItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Select a different menu item via enter + let nextSelectedItem = options[4]; + fireEvent.keyDown(nextSelectedItem, {key: 'Enter', code: 13, charCode: 13}); + expect(nextSelectedItem).toHaveAttribute('aria-selected', 'false'); + expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + checkmark = within(selectedItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Make sure there is only a single checkmark in the entire menu + let checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(1); + + expect(onSelectionChange).toBeCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0].has('Bleh')).toBeTruthy(); + }); + + it('supports using space key to change item selection', async function () { + let tree = renderComponent({onSelectionChange}); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + + // Trigger a menu item via space + let item = options[4]; + fireEvent.keyDown(item, {key: ' ', code: 32, charCode: 32}); + expect(item).toHaveAttribute('aria-selected', 'true'); + let checkmark = within(item).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Make sure there is only a single checkmark in the entire menu + let checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(1); + + // Verify onSelectionChange is called + expect(onSelectionChange).toBeCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0].has('Bleh')).toBeTruthy(); + }); + + it('supports using click to change item selection', async function () { + let tree = renderComponent({onSelectionChange}); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + + // Trigger a menu item via press + let item = options[4]; + triggerPress(item); + expect(item).toHaveAttribute('aria-selected', 'true'); + let checkmark = within(item).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Make sure there is only a single checkmark in the entire menu + let checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(1); + + // Verify onSelectionChange is called + expect(onSelectionChange).toBeCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0].has('Bleh')).toBeTruthy(); + }); + + it('supports disabled items', function () { + let tree = renderComponent({onSelectionChange, disabledKeys: ['Baz'], autoFocus: true}); + let listbox = tree.getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + + // Attempt to trigger the disabled item + let disabledItem = options[2]; + triggerPress(disabledItem); + expect(disabledItem).toHaveAttribute('aria-selected', 'false'); + expect(disabledItem).toHaveAttribute('aria-disabled', 'true'); + + // Make sure there are no checkmarks + let checkmarks = tree.queryAllByRole('img'); + expect(checkmarks.length).toBe(0); + + // Verify onSelectionChange is not called + expect(onSelectionChange).toBeCalledTimes(0); + + // Verify that keyboard navigation skips disabled items + expect(document.activeElement).toBe(options[0]); + fireEvent.keyDown(listbox, {key: 'ArrowDown', code: 40, charCode: 40}); + expect(document.activeElement).toBe(options[1]); + fireEvent.keyDown(listbox, {key: 'ArrowDown', code: 40, charCode: 40}); + expect(document.activeElement).toBe(options[3]); + fireEvent.keyDown(listbox, {key: 'ArrowUp', code: 38, charCode: 38}); + expect(document.activeElement).toBe(options[1]); + fireEvent.keyDown(listbox, {key: 'ArrowUp', code: 38, charCode: 38}); + expect(document.activeElement).toBe(options[0]); + }); + }); + + describe('supports multi selection', function () { + it('supports selecting multiple items', async function () { + let tree = renderComponent({onSelectionChange, selectionMode: 'multiple'}); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + + // Make sure nothing is checked by default + let checkmarks = tree.queryAllByRole('img'); + expect(checkmarks.length).toBe(0); + + let options = within(listbox).getAllByRole('option'); + let firstItem = options[3]; + triggerPress(firstItem); + expect(firstItem).toHaveAttribute('aria-selected', 'true'); + let checkmark = within(firstItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Select a different menu item + let secondItem = options[1]; + triggerPress(secondItem); + expect(secondItem).toHaveAttribute('aria-selected', 'true'); + checkmark = within(secondItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Make sure there are multiple checkmark in the entire menu + checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(2); + + expect(onSelectionChange).toBeCalledTimes(2); + expect(onSelectionChange.mock.calls[0][0].has('Blah')).toBeTruthy(); + expect(onSelectionChange.mock.calls[1][0].has('Bar')).toBeTruthy(); + }); + + it('supports multiple defaultSelectedKeys (uncontrolled)', async function () { + let tree = renderComponent({onSelectionChange, selectionMode: 'multiple', defaultSelectedKeys: ['Foo', 'Bar']}); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + + // Make sure two items are checked by default + let checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(2); + + let options = within(listbox).getAllByRole('option'); + let firstItem = options[0]; + let secondItem = options[1]; + + expect(firstItem).toHaveAttribute('aria-selected', 'true'); + expect(secondItem).toHaveAttribute('aria-selected', 'true'); + let itemText = within(firstItem).getByText('Foo'); + expect(itemText).toBeTruthy(); + itemText = within(secondItem).getByText('Bar'); + expect(itemText).toBeTruthy(); + let checkmark = within(firstItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + checkmark = within(secondItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Select a different menu item + let thirdItem = options[4]; + triggerPress(thirdItem); + expect(thirdItem).toHaveAttribute('aria-selected', 'true'); + checkmark = within(thirdItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Make sure there are now three checkmarks + checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(3); + + expect(onSelectionChange).toBeCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0].has('Bleh')).toBeTruthy(); + expect(onSelectionChange.mock.calls[0][0].has('Foo')).toBeTruthy(); + expect(onSelectionChange.mock.calls[0][0].has('Bar')).toBeTruthy(); + }); + + it('supports multiple selectedKeys (controlled)', async function () { + let tree = renderComponent({onSelectionChange, selectionMode: 'multiple', selectedKeys: ['Foo', 'Bar']}); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + + // Make sure two items are checked by default + let checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(2); + + let options = within(listbox).getAllByRole('option'); + let firstItem = options[0]; + let secondItem = options[1]; + + expect(firstItem).toHaveAttribute('aria-selected', 'true'); + expect(secondItem).toHaveAttribute('aria-selected', 'true'); + let itemText = within(firstItem).getByText('Foo'); + expect(itemText).toBeTruthy(); + itemText = within(secondItem).getByText('Bar'); + expect(itemText).toBeTruthy(); + let checkmark = within(firstItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + checkmark = within(secondItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Select a different menu item + let thirdItem = options[4]; + triggerPress(thirdItem); + expect(thirdItem).toHaveAttribute('aria-selected', 'false'); + checkmark = within(thirdItem).queryByRole('img'); + expect(checkmark).toBeNull(); + + // Make sure there are still two checkmarks + checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(2); + + expect(onSelectionChange).toBeCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0].has('Bleh')).toBeTruthy(); + }); + + it('supports deselection', async function () { + let tree = renderComponent({onSelectionChange, selectionMode: 'multiple', defaultSelectedKeys: ['Foo', 'Bar']}); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + + // Make sure two items are checked by default + let checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(2); + + let options = within(listbox).getAllByRole('option'); + let firstItem = options[0]; + let secondItem = options[1]; + + expect(firstItem).toHaveAttribute('aria-selected', 'true'); + expect(secondItem).toHaveAttribute('aria-selected', 'true'); + let itemText = within(firstItem).getByText('Foo'); + expect(itemText).toBeTruthy(); + itemText = within(secondItem).getByText('Bar'); + expect(itemText).toBeTruthy(); + let checkmark = within(firstItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + checkmark = within(secondItem).getByRole('img'); + expect(checkmark).toBeTruthy(); + + // Deselect the first item + triggerPress(firstItem); + expect(firstItem).toHaveAttribute('aria-selected', 'false'); + checkmark = within(firstItem).queryByRole('img'); + expect(checkmark).toBeNull(); + + // Make sure there only a single checkmark now + checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(1); + + expect(onSelectionChange).toBeCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0].has('Bar')).toBeTruthy(); + }); + + it('supports disabledKeys', async function () { + let tree = renderComponent({onSelectionChange, selectionMode: 'multiple', defaultSelectedKeys: ['Foo', 'Bar'], disabledKeys: ['Baz']}); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + + // Attempt to trigger disabled item + let options = within(listbox).getAllByRole('option'); + let disabledItem = options[2]; + triggerPress(disabledItem); + + expect(disabledItem).toHaveAttribute('aria-selected', 'false'); + expect(disabledItem).toHaveAttribute('aria-disabled', 'true'); + + // Make sure that only two items are checked still + let checkmarks = tree.getAllByRole('img'); + expect(checkmarks.length).toBe(2); + + expect(onSelectionChange).toBeCalledTimes(0); + }); + }); + + describe('supports no selection', function () { + it('prevents selection of any items', async function () { + let tree = renderComponent({onSelectionChange, selectionMode: 'none'}); + await waitForDomChange(); + let listbox = tree.getByRole('listbox'); + + // Make sure nothing is checked by default + let checkmarks = tree.queryAllByRole('img'); + expect(checkmarks.length).toBe(0); + + // Attempt to select a variety of items via enter, space, and click + let options = within(listbox).getAllByRole('option'); + let firstItem = options[3]; + let secondItem = options[4]; + let thirdItem = options[1]; + triggerPress(firstItem); + fireEvent.keyDown(secondItem, {key: ' ', code: 32, charCode: 32}); + fireEvent.keyDown(thirdItem, {key: 'Enter', code: 13, charCode: 13}); + expect(firstItem).not.toHaveAttribute('aria-selected', 'true'); + expect(secondItem).not.toHaveAttribute('aria-selected', 'true'); + expect(thirdItem).not.toHaveAttribute('aria-selected', 'true'); + + // Make sure nothing is still checked + checkmarks = tree.queryAllByRole('img'); + expect(checkmarks.length).toBe(0); + expect(onSelectionChange).toBeCalledTimes(0); + }); + }); +}); diff --git a/packages/@react-spectrum/menu/package.json b/packages/@react-spectrum/menu/package.json index f83c426b0f1..76eaee672e8 100644 --- a/packages/@react-spectrum/menu/package.json +++ b/packages/@react-spectrum/menu/package.json @@ -32,11 +32,11 @@ "@babel/runtime": "^7.6.2", "@react-aria/collections": "^3.0.0-alpha.2", "@react-aria/focus": "^3.0.0-rc.1", - "@react-aria/i18n": "^3.0.0-rc.1", "@react-aria/interactions": "^3.0.0-rc.1", "@react-aria/menu": "^3.0.0-alpha.2", "@react-aria/overlays": "^3.0.0-alpha.2", "@react-aria/selection": "^3.0.0-alpha.2", + "@react-aria/separator": "^3.0.0-rc.1", "@react-aria/utils": "^3.0.0-rc.1", "@react-spectrum/checkbox": "^3.0.0-rc.1", "@react-spectrum/divider": "^3.0.0-rc.1", diff --git a/packages/@react-spectrum/menu/src/Menu.tsx b/packages/@react-spectrum/menu/src/Menu.tsx index 934c012607b..bc70b48b4fa 100644 --- a/packages/@react-spectrum/menu/src/Menu.tsx +++ b/packages/@react-spectrum/menu/src/Menu.tsx @@ -11,84 +11,58 @@ */ import {classNames, filterDOMProps, useStyleProps} from '@react-spectrum/utils'; -import {CollectionView} from '@react-aria/collections'; -import {Item, ListLayout, Section} from '@react-stately/collections'; import {MenuContext} from './context'; -import {MenuDivider} from './MenuDivider'; -import {MenuHeading} from './MenuHeading'; import {MenuItem} from './MenuItem'; +import {MenuSection} from './MenuSection'; import {mergeProps} from '@react-aria/utils'; -import React, {Fragment, useContext, useMemo} from 'react'; +import React, {useContext, useRef} from 'react'; import {SpectrumMenuProps} from '@react-types/menu'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {useMenu} from '@react-aria/menu'; -import {useProvider} from '@react-spectrum/provider'; -import {useTreeState} from '@react-stately/tree'; - -export {Item, Section}; +import {useTreeState} from '@react-stately/tree'; export function Menu(props: SpectrumMenuProps) { - let {scale} = useProvider(); - let layout = useMemo(() => - new ListLayout({ - estimatedRowHeight: scale === 'large' ? 48 : 32, - estimatedHeadingHeight: scale === 'large' ? 31 : 25 - }) - , [scale]); - let contextProps = useContext(MenuContext); let completeProps = { ...mergeProps(contextProps, props), selectionMode: props.selectionMode || 'single' }; + let ref = useRef(); let state = useTreeState(completeProps); - let {menuProps, menuItemProps} = useMenu(completeProps, state, layout); + let {menuProps} = useMenu({...completeProps, ref}, state); let {styleProps} = useStyleProps(completeProps); - let menuContext = mergeProps(menuProps, completeProps); return ( - - - {(type, item) => { - if (type === 'section') { - // Only render the Divider if it isn't the first Heading (extra equality check to guard against rerenders) - if (item.key === state.tree.getKeys().next().value) { - return ( - - ); - } else { - return ( - - - - - ); - } - } - - return ( - + {[...state.collection].map(item => { + if (item.type === 'section') { + return ( + ); - }} - - + } + + return ( + + ); + })} + ); } diff --git a/packages/@react-spectrum/menu/src/MenuItem.tsx b/packages/@react-spectrum/menu/src/MenuItem.tsx index 8cf7929f7f4..08e0ae3f938 100644 --- a/packages/@react-spectrum/menu/src/MenuItem.tsx +++ b/packages/@react-spectrum/menu/src/MenuItem.tsx @@ -11,29 +11,34 @@ */ import CheckmarkMedium from '@spectrum-icons/ui/CheckmarkMedium'; -import {classNames, filterDOMProps} from '@react-spectrum/utils'; +import {classNames} from '@react-spectrum/utils'; import {FocusRing} from '@react-aria/focus'; import {Grid} from '@react-spectrum/layout'; +import {Node} from '@react-stately/collections'; import React, {useRef} from 'react'; -import {SpectrumMenuItemProps} from '@react-types/menu'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {Text} from '@react-spectrum/typography'; +import {TreeState} from '@react-stately/tree'; import {useMenuContext} from './context'; import {useMenuItem} from '@react-aria/menu'; -export function MenuItem(props: SpectrumMenuItemProps) { +interface MenuItemProps { + item: Node, + state: TreeState, + isVirtualized?: boolean, +} + +export function MenuItem(props: MenuItemProps) { let { item, state, - ...otherProps + isVirtualized } = props; - let menuProps = useMenuContext(); - let { onClose, closeOnSelect - } = menuProps; + } = useMenuContext(); let { rendered, @@ -42,24 +47,23 @@ export function MenuItem(props: SpectrumMenuItemProps) { key } = item; - let ref = useRef(); + let ref = useRef(); let {menuItemProps} = useMenuItem( { isSelected, isDisabled, key, - ...otherProps + onClose, + closeOnSelect, + ref, + isVirtualized }, - ref, - state, - onClose, - closeOnSelect + state ); return ( -
    (props: SpectrumMenuItemProps) { styles, 'spectrum-Menu-itemGrid' ) - } + } slots={{ text: styles['spectrum-Menu-itemLabel'], end: styles['spectrum-Menu-end'], icon: styles['spectrum-Menu-icon'], description: styles['spectrum-Menu-description'], - keyboard: styles['spectrum-Menu-keyboard']}}> + keyboard: styles['spectrum-Menu-keyboard'] + }}> {!Array.isArray(rendered) && ( {rendered} @@ -91,7 +96,7 @@ export function MenuItem(props: SpectrumMenuItemProps) { {Array.isArray(rendered) && rendered} {isSelected && (props: SpectrumMenuItemProps) { } /> } -
    +
    ); } diff --git a/packages/@react-spectrum/menu/src/MenuSection.tsx b/packages/@react-spectrum/menu/src/MenuSection.tsx new file mode 100644 index 00000000000..96a2be195dc --- /dev/null +++ b/packages/@react-spectrum/menu/src/MenuSection.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {classNames} from '@react-spectrum/utils'; +import {MenuItem} from './MenuItem'; +import {Node} from '@react-stately/collections'; +import React, {Fragment} from 'react'; +import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; +import {TreeState} from '@react-stately/tree'; +import {useMenuSection} from '@react-aria/menu'; +import {useSeparator} from '@react-aria/separator'; + +interface MenuSectionProps { + item: Node, + state: TreeState +} + +export function MenuSection(props: MenuSectionProps) { + let {item, state} = props; + let {itemProps, headingProps, groupProps} = useMenuSection(); + let {separatorProps} = useSeparator({ + elementType: 'li' + }); + + return ( + + {item.key !== state.collection.getFirstKey() && +
  • + } +
  • + {item.rendered && + + {item.rendered} + + } +
      + {[...item.childNodes].map(node => ( + + ))} +
    +
  • +
    + ); +} diff --git a/packages/@react-spectrum/menu/src/index.ts b/packages/@react-spectrum/menu/src/index.ts index bcd60686725..ef9924cf678 100644 --- a/packages/@react-spectrum/menu/src/index.ts +++ b/packages/@react-spectrum/menu/src/index.ts @@ -12,3 +12,4 @@ export * from './MenuTrigger'; export * from './Menu'; +export {Item, Section} from '@react-stately/collections'; diff --git a/packages/@react-spectrum/menu/stories/Menu.stories.tsx b/packages/@react-spectrum/menu/stories/Menu.stories.tsx index 132bb3906e6..766e9388658 100644 --- a/packages/@react-spectrum/menu/stories/Menu.stories.tsx +++ b/packages/@react-spectrum/menu/stories/Menu.stories.tsx @@ -359,39 +359,6 @@ storiesOf('Menu', module) ) ) - .add( - 'Menu with role="listbox"', - () => ( - - - {item => ( -
    - {item => {item.name}} -
    - )} -
    -
    - ) - ) - .add( - 'Menu with role="listbox", static', - () => ( - - -
    - One - Two - Three -
    -
    - Four - Five - Six -
    -
    -
    - ) - ) .add( 'Menu with autoFocus=true', () => ( diff --git a/packages/@react-spectrum/menu/test/Menu.test.js b/packages/@react-spectrum/menu/test/Menu.test.js index b37c4428543..78b1ddd985e 100644 --- a/packages/@react-spectrum/menu/test/Menu.test.js +++ b/packages/@react-spectrum/menu/test/Menu.test.js @@ -92,6 +92,7 @@ describe('Menu', function () { beforeAll(function () { offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 1000); offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 1000); + window.HTMLElement.prototype.scrollIntoView = jest.fn(); }); afterEach(() => { @@ -108,25 +109,21 @@ describe('Menu', function () { Name | Component | props ${'Menu'} | ${Menu} | ${{}} ${'V2Menu'} | ${V2Menu} | ${{}} - `('$Name renders properly', async function ({Component}) { + `('$Name renders properly', function ({Component}) { let tree = renderComponent(Component); - await waitForDomChange(); let menu = tree.getByRole('menu'); expect(menu).toBeTruthy(); if (Component === Menu) { - expect(menu).toHaveAttribute('aria-orientation', 'vertical'); - } - - let headings = within(menu).getAllByRole('heading'); - expect(headings.length).toBe(2); - - for (let heading of headings) { - expect(heading).toHaveAttribute('aria-level', '3'); + let sections = within(menu).getAllByRole('group'); + expect(sections.length).toBe(2); + + for (let section of sections) { + expect(section).toHaveAttribute('aria-labelledby'); + let heading = document.getElementById(section.getAttribute('aria-labelledby')); + expect(heading).toBeTruthy(); + expect(heading).toHaveAttribute('aria-hidden', 'true'); + } } - let heading1 = within(menu).getByText('Heading 1'); - let heading2 = within(menu).getByText('Heading 2'); - expect(heading1).toBeTruthy(); - expect(heading2).toBeTruthy(); let dividers = within(menu).getAllByRole('separator'); expect(dividers.length).toBe(1); @@ -178,11 +175,8 @@ describe('Menu', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{autoFocus: true, wrapAround: true}} - `('$Name wraps focus from first to last/last to first item if up/down arrow is pressed if wrapAround is true', async function ({Component, props}) { + `('$Name wraps focus from first to last/last to first item if up/down arrow is pressed if wrapAround is true', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - if (Component === V2Menu) { - await waitForDomChange(); - } let menu = tree.getByRole('menu'); let menuItems = within(menu).getAllByRole('menuitemradio'); let firstItem = menuItems[0]; @@ -193,34 +187,14 @@ describe('Menu', function () { fireEvent.keyDown(lastItem, {key: 'ArrowDown', code: 40, charCode: 40}); expect(firstItem).toBe(document.activeElement); }); - - it.each` - Name | Component | props - ${'Menu'} | ${Menu} | ${{role: 'listbox', defaultSelectedKeys: ['Blah']}} - `('$Name renders with the right aria props if menu role is listbox', async function ({Component, props}) { - let tree = renderComponent(Component, {}, props); - await waitForDomChange(); - let menu = tree.getByRole('listbox'); - let menuItems = within(menu).getAllByRole('option'); - expect(menuItems.length).toBe(5); - - let selectedItem = menuItems[3]; - expect(selectedItem).toHaveAttribute('aria-selected', 'true'); - - let nonSelectedItem = menuItems[1]; - expect(nonSelectedItem).toHaveAttribute('aria-selected', 'false'); - }); describe('supports single selection', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, defaultSelectedKeys: ['Blah'], autoFocus: true}} - `('$Name supports defaultSelectedKeys (uncontrolled)', async function ({Component, props}) { + `('$Name supports defaultSelectedKeys (uncontrolled)', function ({Component, props}) { // Check that correct menu item is selected by default let tree = renderComponent(Component, {}, props); - if (Component === V2Menu) { - await waitForDomChange(); - } let menu = tree.getByRole('menu'); let menuItems = within(menu).getAllByRole('menuitemradio'); let selectedItem = menuItems[3]; @@ -252,12 +226,9 @@ describe('Menu', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, selectedKeys: ['Blah'], autoFocus: true}} - `('$Name supports selectedKeys (controlled)', async function ({Component, props}) { + `('$Name supports selectedKeys (controlled)', function ({Component, props}) { // Check that correct menu item is selected by default let tree = renderComponent(Component, {}, props); - if (Component === V2Menu) { - await waitForDomChange(); - } let menu = tree.getByRole('menu'); let menuItems = within(menu).getAllByRole('menuitemradio'); let selectedItem = menuItems[3]; @@ -289,9 +260,8 @@ describe('Menu', function () { Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange}} ${'V2Menu'} | ${V2Menu} | ${{onSelect}} - `('$Name supports using space key to change item selection', async function ({Component, props}) { + `('$Name supports using space key to change item selection', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); let menuItems = within(menu).getAllByRole('menuitemradio'); @@ -322,9 +292,8 @@ describe('Menu', function () { Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange}} ${'V2Menu'} | ${V2Menu} | ${{onSelect}} - `('$Name supports using click to change item selection', async function ({Component, props}) { + `('$Name supports using click to change item selection', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); let menuItems = within(menu).getAllByRole('menuitemradio'); @@ -355,9 +324,8 @@ describe('Menu', function () { Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, disabledKeys: ['Baz']}} ${'V2Menu'} | ${V2Menu} | ${{onSelect}} - `('$Name supports disabled items', async function ({Component, props}) { + `('$Name supports disabled items', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); let menuItems = within(menu).getAllByRole('menuitemradio'); @@ -384,9 +352,8 @@ describe('Menu', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, selectionMode: 'multiple'}} - `('$Name supports selecting multiple items', async function ({Component, props}) { + `('$Name supports selecting multiple items', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); // Make sure nothing is checked by default @@ -419,9 +386,8 @@ describe('Menu', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, selectionMode: 'multiple', defaultSelectedKeys: ['Foo', 'Bar']}} - `('$Name supports multiple defaultSelectedKeys (uncontrolled)', async function ({Component, props}) { + `('$Name supports multiple defaultSelectedKeys (uncontrolled)', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); // Make sure two items are checked by default @@ -463,9 +429,8 @@ describe('Menu', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, selectionMode: 'multiple', selectedKeys: ['Foo', 'Bar']}} - `('$Name supports multiple selectedKeys (controlled)', async function ({Component, props}) { + `('$Name supports multiple selectedKeys (controlled)', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); // Make sure two items are checked by default @@ -505,9 +470,8 @@ describe('Menu', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, selectionMode: 'multiple', defaultSelectedKeys: ['Foo', 'Bar']}} - `('$Name supports deselection', async function ({Component, props}) { + `('$Name supports deselection', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); // Make sure two items are checked by default @@ -546,9 +510,8 @@ describe('Menu', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, selectionMode: 'multiple', defaultSelectedKeys: ['Foo', 'Bar'], disabledKeys: ['Baz']}} - `('$Name supports disabledKeys', async function ({Component, props}) { + `('$Name supports disabledKeys', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); // Attempt to trigger disabled item @@ -571,9 +534,8 @@ describe('Menu', function () { it.each` Name | Component | props ${'Menu'} | ${Menu} | ${{onSelectionChange, selectionMode: 'none'}} - `('$Name prevents selection of any items', async function ({Component, props}) { + `('$Name prevents selection of any items', function ({Component, props}) { let tree = renderComponent(Component, {}, props); - await waitForDomChange(); let menu = tree.getByRole('menu'); // Make sure nothing is checked by default diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index f0701f378e8..54b091ae4a7 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -84,6 +84,7 @@ describe('MenuTrigger', function () { beforeAll(function () { offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 1000); offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 1000); + window.HTMLElement.prototype.scrollIntoView = jest.fn(); }); afterEach(() => { diff --git a/packages/@react-spectrum/sidenav/src/SideNav.tsx b/packages/@react-spectrum/sidenav/src/SideNav.tsx index 7648a2184f0..59a9f88ed4c 100644 --- a/packages/@react-spectrum/sidenav/src/SideNav.tsx +++ b/packages/@react-spectrum/sidenav/src/SideNav.tsx @@ -41,7 +41,7 @@ export function SideNav(props: SideNavProps) { focusedKey={state.selectionManager.focusedKey} className={classNames(styles, 'spectrum-SideNav')} layout={layout} - collection={state.tree}> + collection={state.collection}> {(type, item) => { if (type === 'section') { return ( diff --git a/packages/@react-spectrum/tree/src/Tree.tsx b/packages/@react-spectrum/tree/src/Tree.tsx index 6614d3422aa..81e948e99e0 100644 --- a/packages/@react-spectrum/tree/src/Tree.tsx +++ b/packages/@react-spectrum/tree/src/Tree.tsx @@ -37,18 +37,18 @@ export function Tree(props: CollectionBase & Expandable & MultipleSelectio }) , []); - let {listProps} = useSelectableCollection({ + let {collectionProps} = useSelectableCollection({ selectionManager: state.selectionManager, keyboardDelegate: layout }); return ( + collection={state.collection}> {(type, item) => { if (type === 'section') { return ; diff --git a/packages/@react-stately/button/src/ButtonGroupCollection.ts b/packages/@react-stately/button/src/ButtonGroupCollection.ts index 38f4d697073..63f01729b52 100644 --- a/packages/@react-stately/button/src/ButtonGroupCollection.ts +++ b/packages/@react-stately/button/src/ButtonGroupCollection.ts @@ -22,6 +22,14 @@ export class ButtonGroupCollection implements Collection this.keys = items.filter(item => !item.props.isDisabled).map(child => child.key); } + get size() { + return this.keys.length; + } + + *[Symbol.iterator]() { + yield* this.items; + } + getKeys() { return this.keys; } diff --git a/packages/@react-stately/collections/src/ListLayout.ts b/packages/@react-stately/collections/src/ListLayout.ts index 1613420bc5d..1f21ddd58ba 100644 --- a/packages/@react-stately/collections/src/ListLayout.ts +++ b/packages/@react-stately/collections/src/ListLayout.ts @@ -148,17 +148,37 @@ export class ListLayout extends Layout> implements KeyboardDelegate { } getKeyAbove(key: Key) { - return this.collectionManager.collection.getKeyBefore(key); + let collection = this.collectionManager.collection; + + key = collection.getKeyBefore(key); + while (key) { + let item = collection.getItem(key); + if (item.type === 'item' && !item.isDisabled) { + return key; + } + + key = collection.getKeyBefore(key); + } } getKeyBelow(key: Key) { - return this.collectionManager.collection.getKeyAfter(key); + let collection = this.collectionManager.collection; + + key = collection.getKeyAfter(key); + while (key) { + let item = collection.getItem(key); + if (item.type === 'item' && !item.isDisabled) { + return key; + } + + key = collection.getKeyAfter(key); + } } getKeyPageAbove(key: Key) { let layoutInfo = this.getLayoutInfo('item', key); let pageY = Math.max(0, layoutInfo.rect.y + layoutInfo.rect.height - this.collectionManager.visibleRect.height); - while (layoutInfo.rect.y > pageY && layoutInfo) { + while (layoutInfo && layoutInfo.rect.y > pageY && layoutInfo) { let keyAbove = this.getKeyAbove(layoutInfo.key); layoutInfo = this.getLayoutInfo('item', keyAbove); } @@ -186,11 +206,29 @@ export class ListLayout extends Layout> implements KeyboardDelegate { } getFirstKey() { - return this.collectionManager.collection.getFirstKey(); + let collection = this.collectionManager.collection; + let key = collection.getFirstKey(); + while (key) { + let item = collection.getItem(key); + if (item.type === 'item' && !item.isDisabled) { + return key; + } + + key = collection.getKeyAfter(key); + } } getLastKey() { - return this.collectionManager.collection.getLastKey(); + let collection = this.collectionManager.collection; + let key = collection.getLastKey(); + while (key) { + let item = collection.getItem(key); + if (item.type === 'item' && !item.isDisabled) { + return key; + } + + key = collection.getKeyBefore(key); + } } // getDragTarget(point: Point): DragTarget { diff --git a/packages/@react-stately/collections/src/TreeCollection.ts b/packages/@react-stately/collections/src/TreeCollection.ts index d2eebda5bd8..69327d18fee 100644 --- a/packages/@react-stately/collections/src/TreeCollection.ts +++ b/packages/@react-stately/collections/src/TreeCollection.ts @@ -37,11 +37,8 @@ export class TreeCollection implements Collection> { } let last: Node; + let index = 0; for (let [key, node] of this.keyMap) { - if (node.type !== 'item') { - continue; - } - if (last) { last.nextKey = key; node.prevKey = last.key; @@ -49,6 +46,10 @@ export class TreeCollection implements Collection> { this.firstKey = key; } + if (node.type === 'item') { + node.index = index++; + } + last = node; } @@ -59,6 +60,10 @@ export class TreeCollection implements Collection> { yield* this.iterable; } + get size() { + return this.keyMap.size; + } + getKeys() { return this.keyMap.keys(); } diff --git a/packages/@react-stately/collections/src/types.ts b/packages/@react-stately/collections/src/types.ts index 5f88a455819..c25b09cd71d 100644 --- a/packages/@react-stately/collections/src/types.ts +++ b/packages/@react-stately/collections/src/types.ts @@ -31,11 +31,13 @@ export interface Node extends ItemStates { hasChildNodes: boolean, childNodes: Iterable>, rendered: ReactNode, + index?: number, prevKey?: Key, nextKey?: Key } -export interface Collection { +export interface Collection extends Iterable { + readonly size: number; getKeys(): Iterable, getItem(key: Key): T, getKeyBefore(key: Key): Key | null, diff --git a/packages/@react-stately/list/index.ts b/packages/@react-stately/list/index.ts new file mode 100644 index 00000000000..bbd9b8c2c84 --- /dev/null +++ b/packages/@react-stately/list/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-stately/list/package.json b/packages/@react-stately/list/package.json new file mode 100644 index 00000000000..d24b21d720e --- /dev/null +++ b/packages/@react-stately/list/package.json @@ -0,0 +1,29 @@ +{ + "name": "@react-stately/list", + "version": "3.0.0-alpha.1", + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "private": true, + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe-private/react-spectrum-v3" + }, + "dependencies": { + "@babel/runtime": "^7.6.2", + "@react-stately/collections": "^3.0.0-alpha.1", + "@react-stately/selection": "^3.0.0-alpha.1", + "@react-stately/utils": "^3.0.0-alpha.1" + }, + "peerDependencies": { + "react": "^16.8.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-stately/list/src/index.ts b/packages/@react-stately/list/src/index.ts new file mode 100644 index 00000000000..62f64f66234 --- /dev/null +++ b/packages/@react-stately/list/src/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './useListState'; diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts new file mode 100644 index 00000000000..bef7b616643 --- /dev/null +++ b/packages/@react-stately/list/src/useListState.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Collection, CollectionBuilder, Node, TreeCollection} from '@react-stately/collections'; +import {CollectionBase, MultipleSelection} from '@react-types/shared'; +import {Key, useMemo} from 'react'; +import {SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; + +export interface ListState { + collection: Collection>, + disabledKeys: Set, + selectionManager: SelectionManager +} + +export function useListState(props: CollectionBase & MultipleSelection): ListState { + let selectionState = useMultipleSelectionState(props); + let disabledKeys = useMemo(() => + props.disabledKeys ? new Set(props.disabledKeys) : new Set() + , [props.disabledKeys]); + + let builder = useMemo(() => new CollectionBuilder(props.itemKey), [props.itemKey]); + let collection = useMemo(() => { + let nodes = builder.build(props, (key) => ({ + isSelected: selectionState.selectedKeys.has(key), + isDisabled: disabledKeys.has(key), + isFocused: key === selectionState.focusedKey + })); + + return new TreeCollection(nodes); + }, [builder, props, selectionState.selectedKeys, selectionState.focusedKey, disabledKeys]); + + return { + collection, + disabledKeys, + selectionManager: new SelectionManager(collection, selectionState) + }; +} diff --git a/packages/@react-stately/tree/src/useTreeState.ts b/packages/@react-stately/tree/src/useTreeState.ts index bc01285f23f..d13465a7418 100644 --- a/packages/@react-stately/tree/src/useTreeState.ts +++ b/packages/@react-stately/tree/src/useTreeState.ts @@ -10,14 +10,14 @@ * governing permissions and limitations under the License. */ +import {Collection, CollectionBuilder, Node, TreeCollection} from '@react-stately/collections'; import {CollectionBase, Expandable, MultipleSelection} from '@react-types/shared'; -import {CollectionBuilder, TreeCollection} from '@react-stately/collections'; -import {Key, useMemo, useState} from 'react'; +import {Key, useMemo} from 'react'; import {SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; import {useControlledState} from '@react-stately/utils'; export interface TreeState { - tree: TreeCollection, + collection: Collection>, expandedKeys: Set, disabledKeys: Set, toggleKey: (key: Key) => void, @@ -32,10 +32,9 @@ export function useTreeState(props: CollectionBase & Expandable & Multiple ); let selectionState = useMultipleSelectionState(props); - - let [disabledKeys] = useState( + let disabledKeys = useMemo(() => props.disabledKeys ? new Set(props.disabledKeys) : new Set() - ); + , [props.disabledKeys]); let builder = useMemo(() => new CollectionBuilder(props.itemKey), [props.itemKey]); let tree = useMemo(() => { @@ -54,7 +53,7 @@ export function useTreeState(props: CollectionBase & Expandable & Multiple }; return { - tree, + collection: tree, expandedKeys, disabledKeys, toggleKey: onToggle, diff --git a/packages/@react-types/menu/src/index.d.ts b/packages/@react-types/menu/src/index.d.ts index 6191a49c6a7..5e586cd328c 100644 --- a/packages/@react-types/menu/src/index.d.ts +++ b/packages/@react-types/menu/src/index.d.ts @@ -10,10 +10,9 @@ * governing permissions and limitations under the License. */ -import {AllHTMLAttributes, Key, ReactElement, RefObject} from 'react'; -import {CollectionBase, DOMProps, Expandable, MultipleSelection, Orientation, StyleProps} from '@react-types/shared'; +import {CollectionBase, DOMProps, Expandable, MultipleSelection, StyleProps} from '@react-types/shared'; import {Node} from '@react-stately/collections'; -import {TreeState} from '@react-stately/tree'; +import {ReactElement, RefObject} from 'react'; export type FocusStrategy = 'first' | 'last'; @@ -42,26 +41,13 @@ export interface SpectrumMenuTriggerProps extends MenuTriggerProps { closeOnSelect?: boolean } -export interface MenuProps extends CollectionBase, Expandable, MultipleSelection, DOMProps, StyleProps { - 'aria-orientation'?: Orientation, +export interface MenuProps extends CollectionBase, Expandable, MultipleSelection { autoFocus?: boolean, focusStrategy?: FocusStrategy, wrapAround?: boolean } -export interface SpectrumMenuProps extends MenuProps { -} - -export interface MenuItemProps { - isDisabled?: boolean, - isSelected?: boolean, - key?: Key, - role?: string -} - -export interface SpectrumMenuItemProps extends AllHTMLAttributes { - item: Node, - state: TreeState +export interface SpectrumMenuProps extends MenuProps, DOMProps, StyleProps { } export interface SpectrumMenuHeadingProps {