diff --git a/.storybook/main.js b/.storybook/main.js index 95b64e2cdbb..9bbcbb2dae4 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -7,6 +7,7 @@ module.exports = { ], addons: [ + "@storybook/addon-interactions", "@storybook/addon-actions", "@storybook/addon-a11y", "@storybook/addon-controls", diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 898cebb3f60..6dab9cbd414 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -256,7 +256,8 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, { role: 'row', - onKeyDownCapture: onKeyDown, + onKeyDown: keyboardNavigationBehavior === 'tab' ? onKeyDown : undefined, + onKeyDownCapture: keyboardNavigationBehavior === 'tab' ? undefined : onKeyDown, onFocus, // 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '), 'aria-label': node.textValue || undefined, diff --git a/packages/react-aria-components/stories/EventLeaks.stories.tsx b/packages/react-aria-components/stories/EventLeaks.stories.tsx new file mode 100644 index 00000000000..25a23f2117d --- /dev/null +++ b/packages/react-aria-components/stories/EventLeaks.stories.tsx @@ -0,0 +1,208 @@ +/* + * Copyright 2022 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 {Button, GridList, GridListItem, GridListItemProps, Group, Label, Tag, TagGroup, TagList} from 'react-aria-components'; +import {CalendarExample, RangeCalendarExample} from './Calendar.stories'; +import {CheckboxExample} from './Checkbox.stories'; +import {CheckboxGroupExample} from './CheckboxGroup.stories'; +import {ColorFieldExample} from './ColorField.stories'; +import {ColorSliderExample} from './ColorSlider.stories'; +import {ColorWheelExample} from './ColorWheel.stories'; +import {ComboBoxExample} from './ComboBox.stories'; +import {DateFieldExample} from './DateField.stories'; +import {DisclosureExample} from './Disclosure.stories'; +import {DropzoneExampleWithDraggableObject} from './Dropzone.stories'; +import {MenuExample} from './Menu.stories'; +import {NumberFieldExample} from './NumberField.stories'; +import {PopoverExample} from './Popover.stories'; +import {RadioGroupExample} from './RadioGroup.stories'; +import React, {useEffect, useRef, useState} from 'react'; +import {SearchFieldExample} from './SearchField.stories'; +import {SelectExample} from './Select.stories'; +import {SliderExample} from './Slider.stories'; +import {SwitchExample} from './Switch.stories'; +import {TabsExample} from './Tabs.stories'; +import {TextfieldExample} from './TextField.stories'; +import {TimeFieldExample} from './TimeField.stories'; +import {ToggleButtonExample} from './ToggleButton.stories'; +import {ToolbarExample} from './Toolbar.stories'; +import {userEvent, within} from '@storybook/testing-library'; + +export default { + title: 'React Aria Components' +}; + +let TagGroupExample = () => ( + + + + News + Travel + Gaming + Shopping + + +); + +let rows = [ + {id: 1, name: 'Button', children: , interactions: [' ', 'Enter']}, + {id: 2, name: 'TextField', children: , interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Escape']}, + {id: 3, name: 'ToggleButton', children: , interactions: [' ', 'Enter']}, + {id: 4, name: 'Slider', children: , interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']}, + {id: 5, name: 'RadioGroup', children: , interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']}, + {id: 6, name: 'NumberField', children: NumberFieldExample.render({}), interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Enter']}, + {id: 7, name: 'TimeField', children: , interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Enter']}, + {id: 8, name: 'DateField', children: , interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Enter']}, + {id: 9, name: 'SearchField', children: , interactions: [' ', 'Enter', 'Escape']}, + {id: 10, name: 'Checkbox', children: , interactions: [' ']}, + {id: 11, name: 'CheckboxGroup', children: , interactions: [' ']}, + {id: 12, name: 'Switch', children: , interactions: [' ', 'Enter']}, + {id: 13, name: 'TagGroup', children: , interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', ' ', 'Enter']}, + {id: 14, name: 'ColorWheel', children: , interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']}, + {id: 15, name: 'ColorField', children: , interactions: ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']}, + {id: 16, name: 'Select', children: , interactions: [' ', 'ArrowDown', 'ArrowDown', 'ArrowUp', 'Enter', 'ArrowDown', 'ArrowDown', 'Enter']}, + {id: 17, name: 'ComboBox', children: , interactions: [' ', 'ArrowDown', 'ArrowDown', 'ArrowUp', 'Enter', 'ArrowDown', 'ArrowDown', 'Escape']}, + {id: 18, name: 'Disclosure', children: , interactions: [' ', 'Enter']}, + {id: 19, name: 'Toolbar', children: , interactions: ['ArrowRight', 'ArrowLeft']}, + {id: 20, name: 'Calendar', children: , interactions: ['Tab', 'Tab', 'ArrowLeft', 'ArrowDown', 'ArrowUp', ' ', 'ArrowRight', 'Enter']}, + {id: 21, name: 'RangeCalendar', children: , interactions: ['Tab', 'Tab', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', ' ', 'ArrowRight', 'Enter']}, + {id: 22, name: 'Tabs', children: , interactions: ['ArrowRight', 'ArrowLeft']}, + {id: 23, name: 'Popover', children: , interactions: [' ', 'Tab', 'Escape', ' ', 'Tab', 'ArrowRight', 'ArrowLeft']}, + {id: 24, name: 'Menu', children: , interactions: [' ', 'ArrowDown', 'ArrowUp', 'Enter', 'ArrowDown', 'Escape']}, + {id: 26, name: 'Dropzone', children: , interactions: [' ', 'Enter', 'Escape', 'Enter', 'Enter']}, + {id: 27, name: 'ColorSlider', children: , interactions: ['ArrowRight', 'ArrowLeft']} +]; + +let INTERACTION_KEYS = new Set([' ', 'Enter', 'Escape']); +let NAVIGATION_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown']); + +const EventGridItem = (props: GridListItemProps) => { + // @ts-expect-error + let {name, children} = props; + let itemRef = useRef(null); + let [leakedNavigationKey, setLeakedNavigationKey] = useState(null); + let [leakedInteractionKey, setLeakedInteractionKey] = useState(null); + + let onLeakedKeyboardEvent = (e) => { + if (NAVIGATION_KEYS.has(e.key)) { + setLeakedNavigationKey(e.key); + let element = e.target as HTMLElement; + let style = element.getAttribute('style'); + e.target.setAttribute('style', `${style}; box-shadow: purple 0 0 0 3px !important;`); + } else if (INTERACTION_KEYS.has(e.key)) { + setLeakedInteractionKey(e.key); + } + }; + + useEffect(() => { + requestAnimationFrame(() => { + itemRef.current?.focus(); + }); + }, [leakedNavigationKey, leakedInteractionKey]); + + let warning = leakedNavigationKey || leakedInteractionKey; + // eslint-disable-next-line no-nested-ternary + let color = warning ? leakedNavigationKey ? 'red' : 'orange' : 'inherit'; + + return ( + + + + + ); +}; + +const Story = () => { + return ( +
+ + + + Orange: Interaction key leaked + Red: Navigation key leaked + + + + + {(item) => } + +
+ ); +}; + +let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +export const EventLeakGrid = { + render: Story, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + let elements = (await canvas.findAllByRole('row')).filter(el => el.className === 'react-aria-GridListItem'); + + for (const [index, element] of elements.entries()) { + if (element.className !== 'react-aria-GridListItem') { + continue; + } + + await userEvent.click(element); + await sleep(50); + + await userEvent.tab(); + + await sleep(50); + + if (!rows[index]) { + console.log('No row found for index', index); + continue; + } + + let interactions = rows[index]?.interactions; + + if (interactions) { + for (const key of interactions) { + await userEvent.keyboard(`{${key}}`); + await sleep(50); + + if (document.activeElement === element) { + break; + } + } + } + } + } +};