From b01500b86495605c1f2bfb9a5a2bd2fe783bf756 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 14 Sep 2023 16:19:20 -0700 Subject: [PATCH] Support links in collection components (#4993) --- .../components/menu/index.css | 9 ++ .../components/tabs/index.css | 9 +- .../components/tags/index.css | 7 +- packages/@react-aria/accordion/package.json | 3 +- packages/@react-aria/actiongroup/package.json | 3 +- .../@react-aria/combobox/src/useComboBox.ts | 21 ++- .../@react-aria/gridlist/src/useGridList.ts | 18 ++- .../gridlist/src/useGridListItem.ts | 10 +- packages/@react-aria/gridlist/src/utils.ts | 3 +- .../@react-aria/interactions/src/usePress.ts | 80 ++++++--- .../interactions/test/usePress.test.js | 140 +++++++++++++--- packages/@react-aria/link/src/useLink.ts | 2 +- packages/@react-aria/listbox/package.json | 3 +- .../@react-aria/listbox/src/useListBox.ts | 25 ++- packages/@react-aria/listbox/src/useOption.ts | 3 +- packages/@react-aria/listbox/src/utils.ts | 3 +- packages/@react-aria/menu/src/useMenu.ts | 3 +- packages/@react-aria/menu/src/useMenuItem.ts | 35 ++-- packages/@react-aria/select/src/useSelect.ts | 1 + packages/@react-aria/selection/package.json | 3 +- .../selection/src/useSelectableCollection.ts | 34 +++- .../selection/src/useSelectableItem.ts | 78 +++++++-- .../selection/src/useSelectableList.ts | 82 ++-------- packages/@react-aria/sidenav/package.json | 3 +- .../sidenav/test/useSideNavItem.test.js | 3 + packages/@react-aria/table/src/useTableRow.ts | 5 +- packages/@react-aria/tabs/package.json | 3 +- packages/@react-aria/tabs/src/useTab.ts | 3 +- packages/@react-aria/tabs/src/useTabList.ts | 3 +- packages/@react-aria/tag/src/useTag.ts | 7 +- packages/@react-aria/tag/src/useTagGroup.ts | 3 +- .../@react-aria/utils/src/filterDOMProps.ts | 19 ++- packages/@react-aria/utils/src/index.ts | 1 + packages/@react-aria/utils/src/openLink.tsx | 127 +++++++++++++++ .../@react-spectrum/accordion/package.json | 1 + .../breadcrumbs/src/BreadcrumbItem.tsx | 50 +++--- .../breadcrumbs/src/Breadcrumbs.tsx | 8 +- .../stories/Breadcrumbs.stories.tsx | 12 ++ .../breadcrumbs/test/Breadcrumbs.test.js | 30 ++++ .../combobox/stories/ComboBox.stories.tsx | 10 ++ .../combobox/test/ComboBox.test.js | 49 ++++++ .../stories/ListViewSelection.stories.tsx | 11 ++ .../list/test/ListView.test.js | 112 +++++++++++++ .../listbox/src/ListBoxOption.tsx | 9 +- .../listbox/stories/ListBox.stories.tsx | 31 ++++ .../listbox/test/ListBox.test.js | 70 ++++++++ packages/@react-spectrum/menu/src/Menu.tsx | 8 +- .../@react-spectrum/menu/src/MenuItem.tsx | 9 +- .../@react-spectrum/menu/src/MenuSection.tsx | 12 +- .../@react-spectrum/menu/src/MenuTrigger.tsx | 2 +- packages/@react-spectrum/menu/src/context.ts | 6 +- .../menu/stories/MenuTrigger.stories.tsx | 23 +++ .../@react-spectrum/menu/test/Menu.test.js | 43 +++++ .../picker/stories/Picker.stories.tsx | 10 ++ .../picker/test/Picker.test.js | 40 +++++ .../table/stories/Table.stories.tsx | 34 ++++ .../@react-spectrum/table/test/Table.test.js | 152 ++++++++++++++++++ packages/@react-spectrum/tabs/src/Tabs.tsx | 18 +-- .../tabs/stories/Tabs.stories.tsx | 32 ++++ .../@react-spectrum/tabs/test/Tabs.test.js | 20 +++ .../tag/stories/TagGroup.stories.tsx | 10 ++ .../@react-spectrum/tag/test/TagGroup.test.js | 24 +++ .../list/src/useSingleSelectListState.ts | 2 +- .../selection/src/SelectionManager.ts | 4 + .../@react-stately/selection/src/types.ts | 4 +- .../@react-types/breadcrumbs/src/index.d.ts | 4 +- .../@react-types/shared/src/collections.d.ts | 3 +- packages/@react-types/shared/src/dom.d.ts | 18 +++ packages/@react-types/table/src/index.d.ts | 4 +- .../react-aria-components/src/Collection.tsx | 4 +- .../react-aria-components/src/ListBox.tsx | 12 +- packages/react-aria-components/src/Menu.tsx | 9 +- packages/react-aria-components/src/Table.tsx | 4 +- packages/react-aria-components/src/Tabs.tsx | 11 +- .../react-aria-components/src/TagGroup.tsx | 3 +- .../stories/index.stories.tsx | 114 ++++++++++--- .../test/GridList.test.js | 104 ++++++++++++ .../test/ListBox.test.js | 104 ++++++++++++ .../react-aria-components/test/Menu.test.js | 41 +++++ .../react-aria-components/test/Table.test.js | 149 +++++++++++++++++ .../react-aria-components/test/Tabs.test.js | 20 +++ .../test/TagGroup.test.js | 54 +++++++ 82 files changed, 1962 insertions(+), 294 deletions(-) create mode 100644 packages/@react-aria/utils/src/openLink.tsx diff --git a/packages/@adobe/spectrum-css-temp/components/menu/index.css b/packages/@adobe/spectrum-css-temp/components/menu/index.css index f141510d2bf..31099dc4d17 100644 --- a/packages/@adobe/spectrum-css-temp/components/menu/index.css +++ b/packages/@adobe/spectrum-css-temp/components/menu/index.css @@ -77,6 +77,7 @@ governing permissions and limitations under the License. .spectrum-Menu-item { cursor: default; position: relative; + display: block; box-sizing: border-box; @@ -89,10 +90,18 @@ governing permissions and limitations under the License. font-style: normal; text-decoration: none; + &[href] { + cursor: pointer; + } + &:focus { outline: none; } + &.is-disabled { + cursor: default; + } + &.is-selected { .spectrum-Menu-checkmark { display: block; diff --git a/packages/@adobe/spectrum-css-temp/components/tabs/index.css b/packages/@adobe/spectrum-css-temp/components/tabs/index.css index 48e0abd176d..46f66b402f6 100644 --- a/packages/@adobe/spectrum-css-temp/components/tabs/index.css +++ b/packages/@adobe/spectrum-css-temp/components/tabs/index.css @@ -68,12 +68,12 @@ governing permissions and limitations under the License. cursor: default; outline: none; + &[href] { + cursor: pointer; + } + &.is-disabled { cursor: default; - - .spectrum-Tabs-itemLabel { - cursor: default; - } } .spectrum-Icon { @@ -107,7 +107,6 @@ governing permissions and limitations under the License. } .spectrum-Tabs-itemLabel { - cursor: default; vertical-align: top; display: inline-block; diff --git a/packages/@adobe/spectrum-css-temp/components/tags/index.css b/packages/@adobe/spectrum-css-temp/components/tags/index.css index b7468f62777..702b294a091 100644 --- a/packages/@adobe/spectrum-css-temp/components/tags/index.css +++ b/packages/@adobe/spectrum-css-temp/components/tags/index.css @@ -48,6 +48,7 @@ governing permissions and limitations under the License. align-items: center; box-sizing: border-box; position: relative; + cursor: default; margin: var(--spectrum-tag-margin); padding-inline-start: calc(var(--spectrum-tag-padding-x) - var(--spectrum-tag-border-size)); @@ -65,7 +66,12 @@ governing permissions and limitations under the License. box-shadow var(--spectrum-global-animation-duration-100) ease-in-out, background-color var(--spectrum-global-animation-duration-100) ease-in-out; + &[data-href] { + cursor: pointer; + } + &.is-disabled { + cursor: default; pointer-events: none; } @@ -100,7 +106,6 @@ governing permissions and limitations under the License. margin-inline-end: var(--spectrum-tag-padding-x); flex: 1 1 auto; font-size: var(--spectrum-tag-text-size); - cursor: default; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; diff --git a/packages/@react-aria/accordion/package.json b/packages/@react-aria/accordion/package.json index 7f90f3af42e..f4d0f8386ac 100644 --- a/packages/@react-aria/accordion/package.json +++ b/packages/@react-aria/accordion/package.json @@ -33,7 +33,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/@react-aria/actiongroup/package.json b/packages/@react-aria/actiongroup/package.json index 920b5ae76c0..91205a5a4ae 100644 --- a/packages/@react-aria/actiongroup/package.json +++ b/packages/@react-aria/actiongroup/package.json @@ -34,7 +34,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 3253d6704f0..5b0aa73fc74 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, PressEvent} from '@react-types/shared'; -import {chain, isAppleDevice, mergeProps, useLabels} from '@react-aria/utils'; +import {chain, isAppleDevice, mergeProps, useLabels, useRouter} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, RefObject, TouchEvent, useEffect, useMemo, useRef} from 'react'; import {getChildNodes, getItemCount} from '@react-stately/collections'; @@ -106,6 +106,8 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta isVirtualized: true }); + let router = useRouter(); + // For textfield specific keydown operations let onKeyDown = (e: BaseEvent>) => { switch (e.key) { @@ -116,7 +118,19 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta e.preventDefault(); } - state.commit(); + // If the focused item is a link, trigger opening it. Items that are links are not selectable. + if (state.isOpen && state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) { + if (e.key === 'Enter') { + let item = listBoxRef.current.querySelector(`[data-key="${state.selectionManager.focusedKey}"]`); + if (item instanceof HTMLAnchorElement) { + router.open(item, e); + } + } + + state.close(); + } else { + state.commit(); + } break; case 'Escape': if ( @@ -330,7 +344,8 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta autoFocus: state.focusStrategy, shouldUseVirtualFocus: true, shouldSelectOnPressUp: true, - shouldFocusOnHover: true + shouldFocusOnHover: true, + linkBehavior: 'selection' as const }), descriptionProps, errorMessageProps diff --git a/packages/@react-aria/gridlist/src/useGridList.ts b/packages/@react-aria/gridlist/src/useGridList.ts index 4800e31e8b0..3cc9dba3949 100644 --- a/packages/@react-aria/gridlist/src/useGridList.ts +++ b/packages/@react-aria/gridlist/src/useGridList.ts @@ -51,7 +51,15 @@ export interface AriaGridListOptions extends Omit, 'chil * Whether focus should wrap around when the end/start is reached. * @default false */ - shouldFocusWrap?: boolean + shouldFocusWrap?: boolean, + /** + * The behavior of links in the collection. + * - 'action': link behaves like onAction. + * - 'selection': link follows selection interactions (e.g. if URL drives selection). + * - 'override': links override all other interactions (link items are not selectable). + * @default 'action' + */ + linkBehavior?: 'action' | 'selection' | 'override' } export interface GridListAria { @@ -70,7 +78,8 @@ export function useGridList(props: AriaGridListOptions, state: ListState(props: AriaGridListOptions, state: ListState(props: AriaGridListItemOptions, state: ListSt } = props; let {direction} = useLocale(); - let {onAction} = listMap.get(state); + let {onAction, linkBehavior} = listMap.get(state); let descriptionId = useSlotId(); // We need to track the key of the item at the time it was last focused so that we force @@ -77,7 +77,8 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt isVirtualized, shouldSelectOnPressUp, onAction: onAction ? () => onAction(node.key) : undefined, - focus + focus, + linkBehavior }); let onKeyDown = (e: ReactKeyboardEvent) => { @@ -177,7 +178,8 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt } }; - let rowProps: DOMAttributes = mergeProps(itemProps, { + let linkProps = getSyntheticLinkProps(node.props); + let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, { role: 'row', onKeyDownCapture: onKeyDown, onFocus, diff --git a/packages/@react-aria/gridlist/src/utils.ts b/packages/@react-aria/gridlist/src/utils.ts index 6204911f800..0cfebfa329f 100644 --- a/packages/@react-aria/gridlist/src/utils.ts +++ b/packages/@react-aria/gridlist/src/utils.ts @@ -15,7 +15,8 @@ import type {ListState} from '@react-stately/list'; interface ListMapShared { id: string, - onAction: (key: Key) => void + onAction: (key: Key) => void, + linkBehavior?: 'action' | 'selection' | 'override' } // Used to share: diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index dd468796a3f..02a69cb0995 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -17,7 +17,7 @@ import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents} from '@react-types/shared'; -import {focusWithoutScrolling, isVirtualClick, isVirtualPointerEvent, mergeProps, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils'; +import {focusWithoutScrolling, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils'; import {PressResponderContext} from './context'; import {RefObject, useContext, useEffect, useMemo, useRef, useState} from 'react'; @@ -49,11 +49,13 @@ interface PressState { ignoreEmulatedMouseEvents: boolean, ignoreClickAfterPress: boolean, didFirePressStart: boolean, + isTriggeringEvent: boolean, activePointerId: any, target: FocusableElement | null, isOverTarget: boolean, pointerType: PointerType, - userSelect?: string + userSelect?: string, + metaKeyEvents?: Map } interface EventBase { @@ -113,6 +115,8 @@ class PressEvent implements IPressEvent { } } +const LINK_CLICKED = Symbol('linkClicked'); + /** * Handles press interactions across mouse, touch, keyboard, and screen readers. * It normalizes behavior across browsers and platforms, and handles many nuances @@ -141,6 +145,7 @@ export function usePress(props: PressHookProps): PressResult { ignoreEmulatedMouseEvents: false, ignoreClickAfterPress: false, didFirePressStart: false, + isTriggeringEvent: false, activePointerId: null, target: null, isOverTarget: false, @@ -156,6 +161,7 @@ export function usePress(props: PressHookProps): PressResult { } let shouldStopPropagation = true; + state.isTriggeringEvent = true; if (onPressStart) { let event = new PressEvent('pressstart', pointerType, originalEvent); onPressStart(event); @@ -166,6 +172,7 @@ export function usePress(props: PressHookProps): PressResult { onPressChange(true); } + state.isTriggeringEvent = false; state.didFirePressStart = true; setPressed(true); return shouldStopPropagation; @@ -179,6 +186,7 @@ export function usePress(props: PressHookProps): PressResult { state.ignoreClickAfterPress = true; state.didFirePressStart = false; + state.isTriggeringEvent = true; let shouldStopPropagation = true; if (onPressEnd) { @@ -199,17 +207,21 @@ export function usePress(props: PressHookProps): PressResult { shouldStopPropagation &&= event.shouldStopPropagation; } + state.isTriggeringEvent = false; return shouldStopPropagation; }); let triggerPressUp = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => { + let state = ref.current; if (isDisabled) { return; } if (onPressUp) { + state.isTriggeringEvent = true; let event = new PressEvent('pressup', pointerType, originalEvent); onPressUp(event); + state.isTriggeringEvent = false; return event.shouldStopPropagation; } @@ -265,11 +277,19 @@ export function usePress(props: PressHookProps): PressResult { if (shouldStopPropagation) { e.stopPropagation(); } - } else if (e.key === 'Enter' && isHTMLAnchorLink(e.currentTarget)) { - // If the target is a link, we won't have handled this above because we want the default - // browser behavior to open the link when pressing Enter. But we still need to prevent - // default so that elements above do not also handle it (e.g. table row). - e.stopPropagation(); + + // Keep track of the keydown events that occur while the Meta (e.g. Command) key is held. + // macOS has a bug where keyup events are not fired while the Meta key is down. + // When the Meta key itself is released we will get an event for that, and we'll act as if + // all of these other keys were released as well. + // https://bugs.chromium.org/p/chromium/issues/detail?id=1393524 + // https://bugs.webkit.org/show_bug.cgi?id=55291 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 + if (e.metaKey && isMac()) { + state.metaKeyEvents.set(e.key, e.nativeEvent); + } + } else if (e.key === 'Meta') { + state.metaKeyEvents = new Map(); } }, onKeyUp(e) { @@ -282,7 +302,7 @@ export function usePress(props: PressHookProps): PressResult { return; } - if (e && e.button === 0) { + if (e && e.button === 0 && !state.isTriggeringEvent && !openLink.isOpening) { let shouldStopPropagation = true; if (isDisabled) { e.preventDefault(); @@ -290,7 +310,7 @@ export function usePress(props: PressHookProps): PressResult { // If triggered from a screen reader or by using element.click(), // trigger as if it were a keyboard click. - if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) { + if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) { // Ensure the element receives focus (VoiceOver on iOS does not do this) if (!isDisabled && !preventFocusOnPress) { focusWithoutScrolling(e.currentTarget); @@ -317,7 +337,6 @@ export function usePress(props: PressHookProps): PressResult { e.preventDefault(); } - state.isPressed = false; let target = e.target as Element; let shouldStopPropagation = triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target)); removeAllGlobalListeners(); @@ -326,10 +345,26 @@ export function usePress(props: PressHookProps): PressResult { e.stopPropagation(); } - // If the target is a link, trigger the click method to open the URL, - // but defer triggering pressEnd until onClick event handler. - if (state.target instanceof HTMLElement && state.target.contains(target) && (isHTMLAnchorLink(state.target) || state.target.getAttribute('role') === 'link')) { - state.target.click(); + // If a link was triggered with a key other than Enter, open the URL ourselves. + // This means the link has a role override, and the default browser behavior + // only applies when using the Enter key. + if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && state.target.contains(target) && !e[LINK_CLICKED]) { + // Store a hidden property on the event so we only trigger link click once, + // even if there are multiple usePress instances attached to the element. + e[LINK_CLICKED] = true; + openLink(state.target, e, false); + } + + state.isPressed = false; + state.metaKeyEvents?.delete(e.key); + } else if (e.key === 'Meta' && state.metaKeyEvents?.size) { + // If we recorded keydown events that occurred while the Meta key was pressed, + // and those haven't received keyup events already, fire keyup events ourselves. + // See comment above for more info about the macOS bug causing this. + let events = state.metaKeyEvents; + state.metaKeyEvents = null; + for (let event of events.values()) { + state.target.dispatchEvent(new KeyboardEvent('keyup', event)); } } }; @@ -541,7 +576,7 @@ export function usePress(props: PressHookProps): PressResult { } if (!state.ignoreEmulatedMouseEvents && e.button === 0) { - triggerPressUp(e, state.pointerType); + triggerPressUp(e, state.pointerType || 'mouse'); } }; @@ -726,7 +761,7 @@ export function usePress(props: PressHookProps): PressResult { }; } -function isHTMLAnchorLink(target: Element): boolean { +function isHTMLAnchorLink(target: Element): target is HTMLAnchorElement { return target.tagName === 'A' && target.hasAttribute('href'); } @@ -741,11 +776,8 @@ function isValidKeyboardEvent(event: KeyboardEvent, currentTarget: Element): boo !((element instanceof HTMLInputElement && !isValidInputKey(element, key)) || element instanceof HTMLTextAreaElement || element.isContentEditable) && - // A link with a valid href should be handled natively, - // unless it also has role='button' and was triggered using Space. - (!isHTMLAnchorLink(element) || (role === 'button' && key !== 'Enter')) && - // An element with role='link' should only trigger with Enter key - !(role === 'link' && key !== 'Enter') + // Links should only trigger with Enter key + !((role === 'link' || (!role && isHTMLAnchorLink(element))) && key !== 'Enter') ); } @@ -829,7 +861,7 @@ function isOverTarget(point: EventPoint, target: Element) { function shouldPreventDefault(target: Element) { // We cannot prevent default if the target is a draggable element. - return !(target instanceof HTMLElement) || !target.draggable; + return !(target instanceof HTMLElement) || !target.hasAttribute('draggable'); } function shouldPreventDefaultKeyboard(target: Element, key: string) { @@ -841,6 +873,10 @@ function shouldPreventDefaultKeyboard(target: Element, key: string) { return target.type !== 'submit' && target.type !== 'reset'; } + if (isHTMLAnchorLink(target)) { + return false; + } + return true; } diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index de97d754dd0..a24851e7303 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -607,7 +607,7 @@ describe('usePress', function () { let el = res.getByText('test'); fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0})); fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0, clientX: 0, clientY: 0})); - + expect(events).toEqual([ { type: 'pressstart', @@ -1847,23 +1847,19 @@ describe('usePress', function () { // Space key handled should do nothing on a link expect(events).toEqual([]); - fireEvent.keyDown(el, {key: 'Enter'}); + let shouldContinue = fireEvent.keyDown(el, {key: 'Enter'}); + if (shouldContinue) { + // Browser fires click event as default action of keydown event. + fireEvent.click(el); + } fireEvent.keyUp(el, {key: 'Enter'}); - // Enter key should handled natively - expect(events).toEqual([]); - - fireEvent.click(el); - // Click event, which is called when Enter key on a link is handled natively, should trigger a click. expect(events).toEqual([ - { - type: 'click' - }, { type: 'pressstart', target: el, - pointerType: 'virtual', + pointerType: 'keyboard', ctrlKey: false, metaKey: false, shiftKey: false, @@ -1873,10 +1869,13 @@ describe('usePress', function () { type: 'presschange', pressed: true }, + { + type: 'click' + }, { type: 'pressup', target: el, - pointerType: 'virtual', + pointerType: 'keyboard', ctrlKey: false, metaKey: false, shiftKey: false, @@ -1885,7 +1884,7 @@ describe('usePress', function () { { type: 'pressend', target: el, - pointerType: 'virtual', + pointerType: 'keyboard', ctrlKey: false, metaKey: false, shiftKey: false, @@ -1898,7 +1897,7 @@ describe('usePress', function () { { type: 'press', target: el, - pointerType: 'virtual', + pointerType: 'keyboard', ctrlKey: false, metaKey: false, shiftKey: false, @@ -1976,14 +1975,11 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { - type: 'click' } ]); }); - it('should explicitly call click method, but not fire press events, when Space key is triggered on a link with href and role="button"', function () { + it('should explicitly call click method when Space key is triggered on a link with href and role="button"', function () { let events = []; let addEvent = (e) => events.push(e); let {getByText} = render( @@ -2001,12 +1997,6 @@ describe('usePress', function () { let el = getByText('test'); - // Enter key should handled natively - fireEvent.keyDown(el, {key: 'Enter'}); - fireEvent.keyUp(el, {key: 'Enter'}); - - expect(events).toEqual([]); - // Space key handled by explicitly calling click fireEvent.keyDown(el, {key: ' '}); fireEvent.keyUp(el, {key: ' '}); @@ -2126,6 +2116,76 @@ describe('usePress', function () { ]); }); + it('should fire press events when Meta key is held to work around macOS bug', function () { + let events = []; + let addEvent = (e) => events.push(e); + let res = render( + addEvent({type: 'presschange', pressed})} + onPress={addEvent} + onPressUp={addEvent} /> + ); + + let spy = jest.spyOn(window.navigator, 'platform', 'get').mockImplementation(() => 'Mac'); + + let el = res.getByText('test'); + fireEvent.keyDown(el, {key: 'Meta'}); + fireEvent.keyDown(el, {key: 'Enter', metaKey: true}); + // macOS doesn't fire keyup events while Meta key is held. + // we simulate this when the Meta key itself is released. + fireEvent.keyUp(el, {key: 'Meta'}); + spy.mockRestore(); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'keyboard', + ctrlKey: false, + metaKey: true, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressup', + target: el, + pointerType: 'keyboard', + ctrlKey: false, + metaKey: true, + shiftKey: false, + altKey: false + }, + { + type: 'pressend', + target: el, + pointerType: 'keyboard', + ctrlKey: false, + metaKey: true, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + }, + { + type: 'press', + target: el, + pointerType: 'keyboard', + ctrlKey: false, + metaKey: true, + shiftKey: false, + altKey: false + } + ]); + }); + it('should handle when focus moves between keydown and keyup', function () { let events = []; let addEvent = (e) => events.push(e); @@ -2394,6 +2454,38 @@ describe('usePress', function () { } ]); }); + + it('should ignore synthetic events fired during an onPressUp event', function () { + let events = []; + let addEvent = (e) => events.push(e); + let {getByText} = render( + addEvent({type: 'presschange', pressed})} + onPress={addEvent} + onPressUp={e => { + addEvent(e); + e.target.click(); + }} /> + ); + + let el = getByText('test'); + // no on mouse down because this is simulating it coming from another element. + fireEvent.mouseUp(el, {detail: 1}); + + expect(events).toEqual([ + { + type: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + } + ]); + }); }); it('should not focus the target if preventFocusOnPress is true', function () { diff --git a/packages/@react-aria/link/src/useLink.ts b/packages/@react-aria/link/src/useLink.ts index dc53e452c67..92b674685a2 100644 --- a/packages/@react-aria/link/src/useLink.ts +++ b/packages/@react-aria/link/src/useLink.ts @@ -60,7 +60,7 @@ export function useLink(props: AriaLinkOptions, ref: RefObject } let {focusableProps} = useFocusable(props, ref); let {pressProps, isPressed} = usePress({onPress, onPressStart, onPressEnd, isDisabled, ref}); - let domProps = filterDOMProps(otherProps, {labelable: true}); + let domProps = filterDOMProps(otherProps, {labelable: true, isLink: elementType === 'a'}); let interactionHandlers = mergeProps(focusableProps, pressProps); return { diff --git a/packages/@react-aria/listbox/package.json b/packages/@react-aria/listbox/package.json index ded50cb6d9f..faa0e4fe14d 100644 --- a/packages/@react-aria/listbox/package.json +++ b/packages/@react-aria/listbox/package.json @@ -34,7 +34,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/@react-aria/listbox/src/useListBox.ts b/packages/@react-aria/listbox/src/useListBox.ts index b8b8c8ed670..ffdf9bf6913 100644 --- a/packages/@react-aria/listbox/src/useListBox.ts +++ b/packages/@react-aria/listbox/src/useListBox.ts @@ -46,7 +46,16 @@ export interface AriaListBoxOptions extends Omit, 'childr shouldSelectOnPressUp?: boolean, /** Whether options should be focused when the user hovers over them. */ - shouldFocusOnHover?: boolean + shouldFocusOnHover?: boolean, + + /** + * The behavior of links in the collection. + * - 'action': link behaves like onAction. + * - 'selection': link follows selection interactions (e.g. if URL drives selection). + * - 'override': links override all other interactions (link items are not selectable). + * @default 'override' + */ + linkBehavior?: 'action' | 'selection' | 'override' } /** @@ -57,12 +66,21 @@ export interface AriaListBoxOptions extends Omit, 'childr */ export function useListBox(props: AriaListBoxOptions, state: ListState, ref: RefObject): ListBoxAria { let domProps = filterDOMProps(props, {labelable: true}); + let linkBehavior = props.linkBehavior || (state.selectionManager.selectionBehavior === 'replace' ? 'action' : 'override'); + if (state.selectionManager.selectionBehavior === 'toggle' && linkBehavior === 'action') { + // linkBehavior="action" does not work with selectionBehavior="toggle" because there is no way + // to initiate selection (checkboxes are not allowed inside a listbox). Link items will not be + // selectable in this configuration. + linkBehavior = 'override'; + } + let {listProps} = useSelectableList({ ...props, ref, selectionManager: state.selectionManager, collection: state.collection, - disabledKeys: state.disabledKeys + disabledKeys: state.disabledKeys, + linkBehavior }); let {focusWithinProps} = useFocusWithin({ @@ -79,7 +97,8 @@ export function useListBox(props: AriaListBoxOptions, state: ListState, shouldSelectOnPressUp: props.shouldSelectOnPressUp, shouldFocusOnHover: props.shouldFocusOnHover, isVirtualized: props.isVirtualized, - onAction: props.onAction + onAction: props.onAction, + linkBehavior }); let {labelProps, fieldProps} = useLabel({ diff --git a/packages/@react-aria/listbox/src/useOption.ts b/packages/@react-aria/listbox/src/useOption.ts index 8c5a45059df..b5f24eb1ad8 100644 --- a/packages/@react-aria/listbox/src/useOption.ts +++ b/packages/@react-aria/listbox/src/useOption.ts @@ -133,7 +133,8 @@ export function useOption(props: AriaOptionProps, state: ListState, ref: R isVirtualized, shouldUseVirtualFocus, isDisabled, - onAction: data?.onAction ? () => data?.onAction?.(key) : undefined + onAction: data?.onAction ? () => data?.onAction?.(key) : undefined, + linkBehavior: data?.linkBehavior }); let {hoverProps} = useHover({ diff --git a/packages/@react-aria/listbox/src/utils.ts b/packages/@react-aria/listbox/src/utils.ts index 24e86e84513..9a60da278ce 100644 --- a/packages/@react-aria/listbox/src/utils.ts +++ b/packages/@react-aria/listbox/src/utils.ts @@ -19,7 +19,8 @@ interface ListData { shouldFocusOnHover?: boolean, shouldUseVirtualFocus?: boolean, isVirtualized?: boolean, - onAction?: (key: Key) => void + onAction?: (key: Key) => void, + linkBehavior?: 'action' | 'selection' | 'override' } export const listData = new WeakMap, ListData>(); diff --git a/packages/@react-aria/menu/src/useMenu.ts b/packages/@react-aria/menu/src/useMenu.ts index 55755b8d005..f9624255f95 100644 --- a/packages/@react-aria/menu/src/useMenu.ts +++ b/packages/@react-aria/menu/src/useMenu.ts @@ -63,7 +63,8 @@ export function useMenu(props: AriaMenuOptions, state: TreeState, ref: selectionManager: state.selectionManager, collection: state.collection, disabledKeys: state.disabledKeys, - shouldFocusWrap + shouldFocusWrap, + linkBehavior: 'override' }); menuData.set(state, { diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index b6b57bd12db..86f02cba370 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -16,7 +16,7 @@ import {getItemCount} from '@react-stately/collections'; import {isFocusVisible, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {Key, RefObject, useCallback, useRef} from 'react'; import {menuData} from './useMenu'; -import {mergeProps, useEffectEvent, useLayoutEffect, useSlotId} from '@react-aria/utils'; +import {mergeProps, useEffectEvent, useLayoutEffect, useRouter, useSlotId} from '@react-aria/utils'; import {TreeState} from '@react-stately/tree'; import {useLocale} from '@react-aria/i18n'; import {useSelectableItem} from '@react-aria/selection'; @@ -134,6 +134,16 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re // eslint-disable-next-line react-hooks/exhaustive-deps }, []); let onAction = isTrigger ? onActionMenuDialogTrigger : props.onAction || data.onAction; + let router = useRouter(); + let performAction = (e: PressEvent) => { + if (onAction) { + onAction(key); + } + + if (e.target instanceof HTMLAnchorElement) { + router.open(e.target, e); + } + }; let role = 'menuitem'; if (state.selectionManager.selectionMode === 'single') { @@ -169,20 +179,18 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re } let onPressStart = (e: PressEvent) => { - if (e.pointerType === 'keyboard' && onAction) { - onAction(key); + if (e.pointerType === 'keyboard') { + performAction(e); } }; let onPressUp = (e: PressEvent) => { if (e.pointerType !== 'keyboard') { - if (onAction) { - onAction(key); - } + performAction(e); // Pressing a menu item should close by default in single selection mode but not multiple // selection mode, except if overridden by the closeOnSelect prop. - if (!isTrigger && onClose && (closeOnSelect ?? state.selectionManager.selectionMode !== 'multiple')) { + if (!isTrigger && onClose && (closeOnSelect ?? (state.selectionManager.selectionMode !== 'multiple' || state.selectionManager.isLink(key)))) { onClose(); } } @@ -193,10 +201,19 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re key, ref, shouldSelectOnPressUp: true, - allowsDifferentPressOrigin: true + allowsDifferentPressOrigin: true, + // Disable all handling of links in useSelectable item + // because we handle it ourselves. The behavior of menus + // is slightly different from other collections because + // actions are performed on key down rather than key up. + linkBehavior: 'none' }); - let {pressProps, isPressed} = usePress({onPressStart, onPressUp, isDisabled: isDisabled || (isTrigger && state.expandedKeys.has(key))}); + let {pressProps, isPressed} = usePress({ + onPressStart, + onPressUp, + isDisabled: isDisabled || (isTrigger && state.expandedKeys.has(key)) + }); let {hoverProps} = useHover({ isDisabled, onHoverStart() { diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts index 5f1399fc9f9..124a0a237e0 100644 --- a/packages/@react-aria/select/src/useSelect.ts +++ b/packages/@react-aria/select/src/useSelect.ts @@ -185,6 +185,7 @@ export function useSelect(props: AriaSelectOptions, state: SelectState, shouldSelectOnPressUp: true, shouldFocusOnHover: true, disallowEmptySelection: true, + linkBehavior: 'selection', onBlur: (e) => { if (e.currentTarget.contains(e.relatedTarget as Node)) { return; diff --git a/packages/@react-aria/selection/package.json b/packages/@react-aria/selection/package.json index 3f3b91690aa..181daa1e211 100644 --- a/packages/@react-aria/selection/package.json +++ b/packages/@react-aria/selection/package.json @@ -32,7 +32,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 806575a5949..df489ad31c3 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -11,9 +11,10 @@ */ import {DOMAttributes, FocusableElement, FocusStrategy, KeyboardDelegate} from '@react-types/shared'; +import {flushSync} from 'react-dom'; import {FocusEvent, Key, KeyboardEvent, RefObject, useEffect, useRef} from 'react'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; -import {focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, useEvent} from '@react-aria/utils'; +import {focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, useEvent, useRouter} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils'; import {MultipleSelectionManager} from '@react-stately/selection'; @@ -79,7 +80,15 @@ export interface AriaSelectableCollectionOptions { * The ref attached to the scrollable body. Used to provide automatic scrolling on item focus for non-virtualized collections. * If not provided, defaults to the collection ref. */ - scrollRef?: RefObject + scrollRef?: RefObject, + /** + * The behavior of links in the collection. + * - 'action': link behaves like onAction. + * - 'selection': link follows selection interactions (e.g. if URL drives selection). + * - 'override': links override all other interactions (link items are not selectable). + * @default 'action' + */ + linkBehavior?: 'action' | 'selection' | 'override' } export interface SelectableCollectionAria { @@ -105,10 +114,11 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions allowsTabNavigation = false, isVirtualized, // If no scrollRef is provided, assume the collection ref is the scrollable region - scrollRef = ref + scrollRef = ref, + linkBehavior = 'action' } = options; let {direction} = useLocale(); - + let router = useRouter(); let onKeyDown = (e: KeyboardEvent) => { // Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes @@ -124,8 +134,24 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions const navigateToKey = (key: Key | undefined, childFocus?: FocusStrategy) => { if (key != null) { + if (manager.isLink(key) && linkBehavior === 'selection' && selectOnFocus && !isNonContiguousSelectionModifier(e)) { + // Set focused key and re-render synchronously to bring item into view if needed. + flushSync(() => { + manager.setFocusedKey(key, childFocus); + }); + + let item = scrollRef.current.querySelector(`[data-key="${key}"]`); + router.open(item, e); + + return; + } + manager.setFocusedKey(key, childFocus); + if (manager.isLink(key) && linkBehavior === 'override') { + return; + } + if (e.shiftKey && manager.selectionMode === 'multiple') { manager.extendSelection(key); } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index aea047786b5..06007a9ce1b 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -14,7 +14,7 @@ import {DOMAttributes, FocusableElement, LongPressEvent, PressEvent} from '@reac import {focusSafely} from '@react-aria/focus'; import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils'; import {Key, RefObject, useEffect, useRef} from 'react'; -import {mergeProps} from '@react-aria/utils'; +import {mergeProps, openLink, useRouter} from '@react-aria/utils'; import {MultipleSelectionManager} from '@react-stately/selection'; import {PressProps, useLongPress, usePress} from '@react-aria/interactions'; @@ -59,7 +59,16 @@ export interface SelectableItemOptions { * Handler that is called when a user performs an action on the item. The exact user event depends on * the collection's `selectionBehavior` prop and the interaction modality. */ - onAction?: () => void + onAction?: () => void, + /** + * The behavior of links in the collection. + * - 'action': link behaves like onAction. + * - 'selection': link follows selection interactions (e.g. if URL drives selection). + * - 'override': links override all other interactions (link items are not selectable). + * - 'none': links are disabled for both selection and actions (e.g. handled elsewhere). + * @default 'action' + */ + linkBehavior?: 'action' | 'selection' | 'override' | 'none' } export interface SelectableItemStates { @@ -107,8 +116,10 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte focus, isDisabled, onAction, - allowsDifferentPressOrigin + allowsDifferentPressOrigin, + linkBehavior = 'action' } = options; + let router = useRouter(); let onSelect = (e: PressEvent | LongPressEvent | PointerEvent) => { if (e.pointerType === 'keyboard' && isNonContiguousSelectionModifier(e)) { @@ -118,6 +129,17 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte return; } + if (manager.isLink(key)) { + if (linkBehavior === 'selection') { + router.open(ref.current, e); + // Always set selected keys back to what they were so that select and combobox close. + manager.setSelectedKeys(manager.selectedKeys); + return; + } else if (linkBehavior === 'override' || linkBehavior === 'none') { + return; + } + } + if (manager.selectionMode === 'single') { if (manager.isSelected(key) && !manager.disallowEmptySelection) { manager.toggleSelection(key); @@ -173,12 +195,14 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte // Clicking the checkbox enters selection mode, after which clicking anywhere on any row toggles selection for that row. // With highlight selection, onAction is secondary, and occurs on double click. Single click selects the row. // With touch, onAction occurs on single tap, and long press enters selection mode. - let allowsSelection = !isDisabled && manager.canSelectItem(key); - let allowsActions = onAction && !isDisabled; + let isLinkOverride = manager.isLink(key) && linkBehavior === 'override'; + let hasLinkAction = manager.isLink(key) && linkBehavior !== 'selection' && linkBehavior !== 'none'; + let allowsSelection = !isDisabled && manager.canSelectItem(key) && !isLinkOverride; + let allowsActions = (onAction || hasLinkAction) && !isDisabled; let hasPrimaryAction = allowsActions && ( manager.selectionBehavior === 'replace' ? !allowsSelection - : manager.isEmpty + : !allowsSelection || manager.isEmpty ); let hasSecondaryAction = allowsActions && allowsSelection && manager.selectionBehavior === 'replace'; let hasAction = hasPrimaryAction || hasSecondaryAction; @@ -188,6 +212,16 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte let longPressEnabledOnPressStart = useRef(false); let hadPrimaryActionOnPressStart = useRef(false); + let performAction = (e) => { + if (onAction) { + onAction(); + } + + if (hasLinkAction) { + router.open(ref.current, e); + } + }; + // By default, selection occurs on pointer down. This can be strange if selecting an // item causes the UI to disappear immediately (e.g. menus). // If shouldSelectOnPressUp is true, we use onPressUp instead of onPressStart. @@ -214,19 +248,19 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte return; } - onAction(); - } else if (e.pointerType !== 'keyboard') { + performAction(e); + } else if (e.pointerType !== 'keyboard' && allowsSelection) { onSelect(e); } }; } else { - itemPressProps.onPressUp = (e) => { - if (e.pointerType !== 'keyboard') { + itemPressProps.onPressUp = hasPrimaryAction ? null : (e) => { + if (e.pointerType !== 'keyboard' && allowsSelection) { onSelect(e); } }; - itemPressProps.onPress = hasPrimaryAction ? () => onAction() : null; + itemPressProps.onPress = hasPrimaryAction ? performAction : null; } } else { itemPressProps.onPressStart = (e) => { @@ -238,8 +272,10 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte // For keyboard, select on key down. If there is an action, the Space key selects on key down, // and the Enter key performs onAction on key up. if ( - (e.pointerType === 'mouse' && !hasPrimaryAction) || - (e.pointerType === 'keyboard' && (!onAction || isSelectionKey())) + allowsSelection && ( + (e.pointerType === 'mouse' && !hasPrimaryAction) || + (e.pointerType === 'keyboard' && (!allowsActions || isSelectionKey())) + ) ) { onSelect(e); } @@ -257,8 +293,8 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte (e.pointerType === 'mouse' && hadPrimaryActionOnPressStart.current) ) { if (hasAction) { - onAction(); - } else { + performAction(e); + } else if (allowsSelection) { onSelect(e); } } @@ -274,7 +310,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte if (modality.current === 'mouse') { e.stopPropagation(); e.preventDefault(); - onAction(); + performAction(e); } } : undefined; @@ -301,12 +337,20 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } }; + // Prevent default on link clicks so that we control exactly + // when they open (to match selection behavior). + let onClick = manager.isLink(key) ? e => { + if (!openLink.isOpening) { + e.preventDefault(); + } + } : undefined; + return { itemProps: mergeProps( itemProps, allowsSelection || hasPrimaryAction ? pressProps : {}, longPressEnabled ? longPressProps : {}, - {onDoubleClick, onDragStartCapture} + {onDoubleClick, onDragStartCapture, onClick} ), isPressed, isSelected: manager.isSelected(key), diff --git a/packages/@react-aria/selection/src/useSelectableList.ts b/packages/@react-aria/selection/src/useSelectableList.ts index 0c51a578f88..87acf4f50b8 100644 --- a/packages/@react-aria/selection/src/useSelectableList.ts +++ b/packages/@react-aria/selection/src/useSelectableList.ts @@ -10,71 +10,25 @@ * governing permissions and limitations under the License. */ -import {Collection, DOMAttributes, FocusStrategy, KeyboardDelegate, Node} from '@react-types/shared'; -import {Key, RefObject, useMemo} from 'react'; +import {AriaSelectableCollectionOptions, useSelectableCollection} from './useSelectableCollection'; +import {Collection, DOMAttributes, KeyboardDelegate, Node} from '@react-types/shared'; +import {Key, useMemo} from 'react'; import {ListKeyboardDelegate} from './ListKeyboardDelegate'; -import {MultipleSelectionManager} from '@react-stately/selection'; import {useCollator} from '@react-aria/i18n'; -import {useSelectableCollection} from './useSelectableCollection'; -export interface AriaSelectableListOptions { - /** - * An interface for reading and updating multiple selection state. - */ - selectionManager: MultipleSelectionManager, +export interface AriaSelectableListOptions extends Omit { /** * State of the collection. */ collection: Collection>, /** - * The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. - */ - disabledKeys: Set, - /** - * A ref to the item. - */ - ref?: RefObject, - /** - * A delegate that returns collection item keys with respect to visual layout. + * A delegate object that implements behavior for keyboard focus movement. */ keyboardDelegate?: KeyboardDelegate, /** - * Whether the collection or one of its items should be automatically focused upon render. - * @default false - */ - autoFocus?: boolean | FocusStrategy, - /** - * Whether focus should wrap around when the end/start is reached. - * @default false - */ - shouldFocusWrap?: boolean, - /** - * Whether the option is contained in a virtual scroller. - */ - isVirtualized?: boolean, - /** - * Whether the collection allows empty selection. - * @default false - */ - disallowEmptySelection?: boolean, - /** - * Whether selection should occur automatically on focus. - * @default false - */ - selectOnFocus?: boolean, - /** - * Whether typeahead is disabled. - * @default false - */ - disallowTypeAhead?: boolean, - /** - * Whether the collection items should use virtual focus instead of being focused directly. - */ - shouldUseVirtualFocus?: boolean, - /** - * Whether navigation through tab key is enabled. + * The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. */ - allowsTabNavigation?: boolean + disabledKeys: Set } export interface SelectableListAria { @@ -93,15 +47,7 @@ export function useSelectableList(props: AriaSelectableListOptions): SelectableL collection, disabledKeys, ref, - keyboardDelegate, - autoFocus, - shouldFocusWrap, - isVirtualized, - disallowEmptySelection, - selectOnFocus = selectionManager.selectionBehavior === 'replace', - disallowTypeAhead, - shouldUseVirtualFocus, - allowsTabNavigation + keyboardDelegate } = props; // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). @@ -113,18 +59,10 @@ export function useSelectableList(props: AriaSelectableListOptions): SelectableL ), [keyboardDelegate, collection, disabledKeys, ref, collator, disabledBehavior]); let {collectionProps} = useSelectableCollection({ + ...props, ref, selectionManager, - keyboardDelegate: delegate, - autoFocus, - shouldFocusWrap, - disallowEmptySelection, - selectOnFocus, - disallowTypeAhead, - shouldUseVirtualFocus, - allowsTabNavigation, - isVirtualized, - scrollRef: ref + keyboardDelegate: delegate }); return { diff --git a/packages/@react-aria/sidenav/package.json b/packages/@react-aria/sidenav/package.json index 6550603aa5d..7cd194ce829 100644 --- a/packages/@react-aria/sidenav/package.json +++ b/packages/@react-aria/sidenav/package.json @@ -33,7 +33,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/@react-aria/sidenav/test/useSideNavItem.test.js b/packages/@react-aria/sidenav/test/useSideNavItem.test.js index 2a8f0266be2..4cfc6b29191 100644 --- a/packages/@react-aria/sidenav/test/useSideNavItem.test.js +++ b/packages/@react-aria/sidenav/test/useSideNavItem.test.js @@ -25,6 +25,9 @@ describe('useSideNavItem', function () { }, isDisabled() { return false; + }, + isLink() { + return false; } }, disabledKeys: new Set() diff --git a/packages/@react-aria/table/src/useTableRow.ts b/packages/@react-aria/table/src/useTableRow.ts index e99b469f6fd..d70163d8027 100644 --- a/packages/@react-aria/table/src/useTableRow.ts +++ b/packages/@react-aria/table/src/useTableRow.ts @@ -13,10 +13,10 @@ import {FocusableElement} from '@react-types/shared'; import {getLastItem} from '@react-stately/collections'; import {getRowLabelledBy} from './utils'; +import {getSyntheticLinkProps, mergeProps} from '@react-aria/utils'; import type {GridNode} from '@react-types/grid'; import {GridRowAria, GridRowProps, useGridRow} from '@react-aria/grid'; import {HTMLAttributes, RefObject} from 'react'; -import {mergeProps} from '@react-aria/utils'; import {TableCollection} from '@react-types/table'; import {tableNestedRows} from '@react-stately/flags'; import {TableState, TreeGridState} from '@react-stately/table'; @@ -74,9 +74,10 @@ export function useTableRow(props: GridRowProps, state: TableState | Tr } } + let linkProps = getSyntheticLinkProps(node.props); return { rowProps: { - ...mergeProps(rowProps, treeGridRowProps), + ...mergeProps(rowProps, treeGridRowProps, linkProps), 'aria-labelledby': getRowLabelledBy(state, node.key) }, ...states diff --git a/packages/@react-aria/tabs/package.json b/packages/@react-aria/tabs/package.json index e93644bf179..34377a15730 100644 --- a/packages/@react-aria/tabs/package.json +++ b/packages/@react-aria/tabs/package.json @@ -34,7 +34,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/@react-aria/tabs/src/useTab.ts b/packages/@react-aria/tabs/src/useTab.ts index 4fe6353bbf1..f206bde6930 100644 --- a/packages/@react-aria/tabs/src/useTab.ts +++ b/packages/@react-aria/tabs/src/useTab.ts @@ -48,7 +48,8 @@ export function useTab( key, ref, isDisabled, - shouldSelectOnPressUp + shouldSelectOnPressUp, + linkBehavior: 'selection' }); let tabId = generateId(state, key, 'tab'); diff --git a/packages/@react-aria/tabs/src/useTabList.ts b/packages/@react-aria/tabs/src/useTabList.ts index e69e0fd51a6..a3f0f0cd8f4 100644 --- a/packages/@react-aria/tabs/src/useTabList.ts +++ b/packages/@react-aria/tabs/src/useTabList.ts @@ -54,7 +54,8 @@ export function useTabList(props: AriaTabListOptions, state: TabListState< keyboardDelegate: delegate, selectOnFocus: keyboardActivation === 'automatic', disallowEmptySelection: true, - scrollRef: ref + scrollRef: ref, + linkBehavior: 'selection' }); // Compute base id for all tabs diff --git a/packages/@react-aria/tag/src/useTag.ts b/packages/@react-aria/tag/src/useTag.ts index fa599eaef9d..a0697dcf93e 100644 --- a/packages/@react-aria/tag/src/useTag.ts +++ b/packages/@react-aria/tag/src/useTag.ts @@ -12,7 +12,7 @@ import {AriaButtonProps} from '@react-types/button'; import {DOMAttributes, FocusableElement, Node} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, getSyntheticLinkProps, mergeProps, useDescription, useId} from '@react-aria/utils'; import {hookData} from './useTagGroup'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -80,7 +80,8 @@ export function useTag(props: AriaTagProps, state: ListState, ref: RefO let isFocused = item.key === state.selectionManager.focusedKey; // @ts-ignore - data attributes are ok but TS doesn't know about them. - let domProps = filterDOMProps(props); + let domProps = filterDOMProps(item.props); + let linkProps = getSyntheticLinkProps(item.props); return { removeButtonProps: { 'aria-label': stringFormatter.format('removeButtonLabel'), @@ -89,7 +90,7 @@ export function useTag(props: AriaTagProps, state: ListState, ref: RefO onPress: () => onRemove ? onRemove(new Set([item.key])) : null, excludeFromTabOrder: true }, - rowProps: mergeProps(rowProps, domProps, { + rowProps: mergeProps(rowProps, domProps, linkProps, { tabIndex: (isFocused || state.selectionManager.focusedKey == null) ? 0 : -1, onKeyDown: onRemove ? onKeyDown : undefined, 'aria-describedby': descProps['aria-describedby'] diff --git a/packages/@react-aria/tag/src/useTagGroup.ts b/packages/@react-aria/tag/src/useTagGroup.ts index d0ebf3f1284..7ec486cebbf 100644 --- a/packages/@react-aria/tag/src/useTagGroup.ts +++ b/packages/@react-aria/tag/src/useTagGroup.ts @@ -76,7 +76,8 @@ export function useTagGroup(props: AriaTagGroupOptions, state: ListState elements. */ + isLink?: boolean, /** * A Set of other property names that should be included in the filter. */ @@ -41,8 +53,8 @@ const propRe = /^(data-.*)$/; * @param props - The component props to be filtered. * @param opts - Props to override. */ -export function filterDOMProps(props: DOMProps & AriaLabelingProps, opts: Options = {}): DOMProps & AriaLabelingProps { - let {labelable, propNames} = opts; +export function filterDOMProps(props: DOMProps & AriaLabelingProps & LinkDOMProps, opts: Options = {}): DOMProps & AriaLabelingProps { + let {labelable, isLink, propNames} = opts; let filteredProps = {}; for (const prop in props) { @@ -50,6 +62,7 @@ export function filterDOMProps(props: DOMProps & AriaLabelingProps, opts: Option Object.prototype.hasOwnProperty.call(props, prop) && ( DOMPropNames.has(prop) || (labelable && labelablePropNames.has(prop)) || + (isLink && linkPropNames.has(prop)) || propNames?.has(prop) || propRe.test(prop) ) diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index fc2e435c4b8..a6075043e70 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -16,6 +16,7 @@ export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; export {focusWithoutScrolling} from './focusWithoutScrolling'; export {getOffset} from './getOffset'; +export {openLink, getSyntheticLinkProps, RouterProvider, useRouter} from './openLink'; export {runAfterTransition} from './runAfterTransition'; export {useDrag1D} from './useDrag1D'; export {useGlobalListeners} from './useGlobalListeners'; diff --git a/packages/@react-aria/utils/src/openLink.tsx b/packages/@react-aria/utils/src/openLink.tsx new file mode 100644 index 00000000000..68ce62ce1b0 --- /dev/null +++ b/packages/@react-aria/utils/src/openLink.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2023 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 {focusWithoutScrolling, isWebKit} from './index'; +import {LinkDOMProps} from '@react-types/shared'; +import React, {createContext, ReactNode, useContext, useMemo} from 'react'; + +interface Router { + open: (target: Element, modifiers: Modifiers) => void +} + +const RouterContext = createContext({ + open: openSyntheticLink +}); + +interface RouterProviderProps { + navigate: (path: string) => void, + children: ReactNode +} + +export function RouterProvider(props: RouterProviderProps) { + let {children, navigate} = props; + + let ctx = useMemo(() => ({ + open: (target: Element, modifiers: Modifiers) => { + getSyntheticLink(target, link => { + if ( + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + !modifiers.metaKey && // open in new tab (mac) + !modifiers.ctrlKey && // open in new tab (windows) + !modifiers.altKey && // download + !modifiers.shiftKey + ) { + navigate(link.pathname + link.search + link.hash); + } else { + openLink(link, modifiers); + } + }); + } + }), [navigate]); + + return ( + + {children} + + ); +} + +export function useRouter(): Router { + return useContext(RouterContext); +} + +interface Modifiers { + metaKey?: boolean, + ctrlKey?: boolean, + altKey?: boolean, + shiftKey?: boolean +} + +export function openLink(target: HTMLAnchorElement, modifiers: Modifiers, setOpening = true) { + let {metaKey, ctrlKey, altKey, shiftKey} = modifiers; + // WebKit does not support firing click events with modifier keys, but does support keyboard events. + // https://github.com/WebKit/WebKit/blob/c03d0ac6e6db178f90923a0a63080b5ca210d25f/Source/WebCore/html/HTMLAnchorElement.cpp#L184 + let event = isWebKit() && process.env.NODE_ENV !== 'test' + // @ts-ignore - keyIdentifier is a non-standard property, but it's what webkit expects + ? new KeyboardEvent('keydown', {keyIdentifier: 'Enter', metaKey, ctrlKey, altKey, shiftKey}) + : new MouseEvent('click', {metaKey, ctrlKey, altKey, shiftKey, bubbles: true, cancelable: true}); + openLink.isOpening = setOpening; + focusWithoutScrolling(target); + target.dispatchEvent(event); + openLink.isOpening = false; +} + +openLink.isOpening = false; + +function getSyntheticLink(target: Element, open: (link: HTMLAnchorElement) => void) { + if (target instanceof HTMLAnchorElement) { + open(target); + } else if (target.hasAttribute('data-href')) { + let link = document.createElement('a'); + link.href = target.getAttribute('data-href'); + if (target.hasAttribute('data-target')) { + link.target = target.getAttribute('data-target'); + } + if (target.hasAttribute('data-rel')) { + link.rel = target.getAttribute('data-rel'); + } + if (target.hasAttribute('data-download')) { + link.download = target.getAttribute('data-download'); + } + if (target.hasAttribute('data-ping')) { + link.ping = target.getAttribute('data-ping'); + } + if (target.hasAttribute('data-referrer-policy')) { + link.referrerPolicy = target.getAttribute('data-referrer-policy'); + } + target.appendChild(link); + open(link); + target.removeChild(link); + } +} + +function openSyntheticLink(target: Element, modifiers: Modifiers) { + getSyntheticLink(target, link => openLink(link, modifiers)); +} + +export function getSyntheticLinkProps(props: LinkDOMProps) { + return { + 'data-href': props.href, + 'data-target': props.target, + 'data-rel': props.rel, + 'data-download': props.download, + 'data-ping': props.ping, + 'data-referrer-policy': props.referrerPolicy + }; +} diff --git a/packages/@react-spectrum/accordion/package.json b/packages/@react-spectrum/accordion/package.json index 21043caa747..83d1d1504d2 100644 --- a/packages/@react-spectrum/accordion/package.json +++ b/packages/@react-spectrum/accordion/package.json @@ -54,6 +54,7 @@ }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", "@react-spectrum/provider": "^3.0.0" }, "publishConfig": { diff --git a/packages/@react-spectrum/breadcrumbs/src/BreadcrumbItem.tsx b/packages/@react-spectrum/breadcrumbs/src/BreadcrumbItem.tsx index 7347397e292..3fde8262ccc 100644 --- a/packages/@react-spectrum/breadcrumbs/src/BreadcrumbItem.tsx +++ b/packages/@react-spectrum/breadcrumbs/src/BreadcrumbItem.tsx @@ -12,7 +12,7 @@ import {BreadcrumbItemProps} from '@react-types/breadcrumbs'; import ChevronRightSmall from '@spectrum-icons/ui/ChevronRightSmall'; -import {classNames, getWrappedElement} from '@react-spectrum/utils'; +import {classNames} from '@react-spectrum/utils'; import {FocusRing} from '@react-aria/focus'; import {mergeProps} from '@react-aria/utils'; import React, {Fragment, useRef} from 'react'; @@ -21,42 +21,50 @@ import {useBreadcrumbItem} from '@react-aria/breadcrumbs'; import {useHover} from '@react-aria/interactions'; import {useLocale} from '@react-aria/i18n'; -export function BreadcrumbItem(props: BreadcrumbItemProps) { +interface SpectrumBreadcrumbItemProps extends BreadcrumbItemProps { + isMenu?: boolean +} + +export function BreadcrumbItem(props: SpectrumBreadcrumbItemProps) { let { children, isCurrent, - isDisabled + isDisabled, + isMenu } = props; let {direction} = useLocale(); let ref = useRef(null); + let ElementType: React.ElementType = props.href ? 'a' : 'span'; let {itemProps} = useBreadcrumbItem({ ...props, - elementType: typeof children === 'string' ? 'span' : 'a' + elementType: ElementType }, ref); let {hoverProps, isHovered} = useHover(props); - let element = React.cloneElement( - getWrappedElement(children), - { - ...mergeProps(itemProps, hoverProps), - ref, - className: - classNames( - styles, - 'spectrum-Breadcrumbs-itemLink', - { - 'is-disabled': !isCurrent && isDisabled, - 'is-hovered': isHovered - } - ) - } - ); + // If this item contains a menu button, then it shouldn't be a link. + if (isMenu) { + itemProps = {}; + } return ( - {element} + + {children} + {isCurrent === false && (props: SpectrumBreadcrumbsProps, ref: DOMRef) { // Not using React.Children.toArray because it mutates the key prop. let childArray: ReactElement[] = []; - React.Children.forEach(children, child => { + React.Children.forEach(children, (child, index) => { if (React.isValidElement(child)) { + if (child.key == null) { + child = React.cloneElement(child, {key: index}); + } childArray.push(child); } }); @@ -153,7 +156,7 @@ function Breadcrumbs(props: SpectrumBreadcrumbsProps, ref: DOMRef) { }; let menuItem = ( - + (props: SpectrumBreadcrumbsProps, ref: DOMRef) { ) }> ( + + Example.com + Foo + Bar + Baz + Qux + + ) +}; + function render(props) { return ( diff --git a/packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js b/packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js index 186eee2943d..b638c0c9144 100644 --- a/packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js +++ b/packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js @@ -410,4 +410,34 @@ describe('Breadcrumbs', function () { let breadcrumbs = getByRole('navigation'); expect(breadcrumbs).toHaveAttribute('data-testid', 'test'); }); + + it('should support links', function () { + let {getByRole, getAllByRole} = render( + + + Example.com + Foo + Bar + Baz + Qux + + + ); + + let links = getAllByRole('link'); + expect(links).toHaveLength(3); + expect(links[0]).toHaveAttribute('href', 'https://example.com/foo/bar'); + expect(links[1]).toHaveAttribute('href', 'https://example.com/foo/bar/baz'); + expect(links[2]).toHaveAttribute('href', 'https://example.com/foo/bar/baz/qux'); + + let menuButton = getByRole('button'); + triggerPress(menuButton); + act(() => {jest.runAllTimers();}); + + let menu = getByRole('menu'); + let items = within(menu).getAllByRole('menuitemradio'); + expect(items).toHaveLength(5); + expect(items[0].tagName).toBe('A'); + expect(items[0]).toHaveAttribute('href', 'https://example.com'); + }); }); diff --git a/packages/@react-spectrum/combobox/stories/ComboBox.stories.tsx b/packages/@react-spectrum/combobox/stories/ComboBox.stories.tsx index 113fa058929..d70ff0fc0f6 100644 --- a/packages/@react-spectrum/combobox/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/combobox/stories/ComboBox.stories.tsx @@ -427,6 +427,16 @@ export const WHCM: ComboBoxStory = { ) }; +export const Links: ComboBoxStory = { + render: (args) => ( + + Foo + Bar + Google + + ) +}; + function LoadingExamples(props) { return ( diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 69211ff0aaa..90d6cfde232 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -5421,4 +5421,53 @@ describe('ComboBox', function () { }); }); }); + + describe('links', () => { + beforeAll(function () { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + }); + + afterAll(function () { + jest.clearAllMocks(); + }); + + it.each(['mouse', 'keyboard'])('supports links on items with %s', async (type) => { + let tree = render( + + + One + Two + + + ); + + let combobox = tree.getByRole('combobox'); + let button = tree.getByRole('button'); + triggerPress(button); + act(() => { + jest.runAllTimers(); + }); + + let listbox = tree.getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + expect(items[0].tagName).toBe('A'); + expect(items[0]).toHaveAttribute('href', 'https://google.com'); + expect(items[1].tagName).toBe('A'); + expect(items[1]).toHaveAttribute('href', 'https://adobe.com'); + + if (type === 'mouse') { + triggerPress(items[0]); + } else { + fireEvent.keyDown(combobox, {key: 'Enter'}); + fireEvent.keyUp(combobox, {key: 'Enter'}); + } + act(() => { + jest.runAllTimers(); + }); + + expect(combobox).toHaveValue(''); + expect(listbox).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/@react-spectrum/list/stories/ListViewSelection.stories.tsx b/packages/@react-spectrum/list/stories/ListViewSelection.stories.tsx index f3a56145387..3f2829374b0 100644 --- a/packages/@react-spectrum/list/stories/ListViewSelection.stories.tsx +++ b/packages/@react-spectrum/list/stories/ListViewSelection.stories.tsx @@ -115,6 +115,17 @@ export const DisableFolderSelection: ListViewStory = { name: 'disable folders selection' }; +export const Links = (args) => { + return ( + + Adobe + Google + Apple + New York Times + + ); +}; + export const OnAction: ListViewStory = { render: (args) => ( diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 829373bbb71..6cae5418550 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -1506,4 +1506,116 @@ describe('ListView', function () { expect(document.activeElement).toBe(getRow(tree, 'Item 1')); }); }); + + describe('links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + let trigger = (item, key = 'Enter') => { + if (type === 'mouse') { + triggerPress(item); + } else { + fireEvent.keyDown(item, {key}); + fireEvent.keyUp(item, {key}); + } + }; + + it('should support links with selectionMode="none"', function () { + let {getAllByRole} = render( + + + One + Two + + + ); + + let items = getAllByRole('row'); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + + it.each(['single', 'multiple'])('should support links with selectionStyle="checkbox" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + + One + Two + + + ); + + let items = getAllByRole('row'); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + + await user.click(within(items[0]).getByRole('checkbox')); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick); + trigger(items[1], ' '); + expect(onClick).not.toHaveBeenCalled(); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); + window.removeEventListener('click', onClick); + }); + + it.each(['single', 'multiple'])('should support links with selectionStyle="highlight" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + + One + Two + + + ); + + let items = getAllByRole('row'); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick); + if (type === 'mouse') { + triggerPress(items[0]); + } else { + fireEvent.keyDown(items[0], {key: ' '}); + fireEvent.keyUp(items[0], {key: ' '}); + } + expect(onClick).not.toHaveBeenCalled(); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + window.removeEventListener('click', onClick); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + if (type === 'mouse') { + await user.dblClick(items[0], {pointerType: 'mouse'}); + } else { + fireEvent.keyDown(items[0], {key: 'Enter'}); + fireEvent.keyUp(items[0], {key: 'Enter'}); + } + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + }); + }); }); diff --git a/packages/@react-spectrum/listbox/src/ListBoxOption.tsx b/packages/@react-spectrum/listbox/src/ListBoxOption.tsx index 44c5ea6bcb2..7795535e7c2 100644 --- a/packages/@react-spectrum/listbox/src/ListBoxOption.tsx +++ b/packages/@react-spectrum/listbox/src/ListBoxOption.tsx @@ -43,11 +43,12 @@ export function ListBoxOption(props: OptionProps) { rendered, key } = item; - let domProps = filterDOMProps(item.props); + let ElementType: React.ElementType = item.props.href ? 'a' : 'div'; + let domProps = filterDOMProps(item.props, {isLink: !!item.props.href}); delete domProps.id; let state = useContext(ListBoxContext); - let ref = useRef(); + let ref = useRef(); let {optionProps, labelProps, descriptionProps, isSelected, isDisabled, isFocused} = useOption( { 'aria-label': item['aria-label'], @@ -73,7 +74,7 @@ export function ListBoxOption(props: OptionProps) { return ( -
(props: OptionProps) { -
+
); } diff --git a/packages/@react-spectrum/listbox/stories/ListBox.stories.tsx b/packages/@react-spectrum/listbox/stories/ListBox.stories.tsx index 4236bf10c38..8f04f037d8c 100644 --- a/packages/@react-spectrum/listbox/stories/ListBox.stories.tsx +++ b/packages/@react-spectrum/listbox/stories/ListBox.stories.tsx @@ -988,3 +988,34 @@ export function FocusExample(args = {}) { ); } + +export const Links = (args) => { + return ( + + Adobe + Google + Apple + New York Times + Non link + + ); +}; + +Links.story = { + decorators: [(Story) => ( + + + + )], + args: { + selectionMode: 'none' + }, + argTypes: { + selectionMode: { + control: { + type: 'radio', + options: ['none', 'single', 'multiple'] + } + } + } +}; diff --git a/packages/@react-spectrum/listbox/test/ListBox.test.js b/packages/@react-spectrum/listbox/test/ListBox.test.js index 19e4289e0ef..eca8ec49f14 100644 --- a/packages/@react-spectrum/listbox/test/ListBox.test.js +++ b/packages/@react-spectrum/listbox/test/ListBox.test.js @@ -947,4 +947,74 @@ describe('ListBox', function () { expect(options[0]).toHaveAttribute('aria-setsize', '3'); }); }); + + describe('links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + let trigger = (item) => { + if (type === 'mouse') { + triggerPress(item); + } else { + fireEvent.keyDown(item, {key: 'Enter'}); + fireEvent.keyUp(item, {key: 'Enter'}); + } + }; + + it('should support links with selectionMode="none"', function () { + let {getAllByRole} = render( + + + One + Two + + + ); + + let items = getAllByRole('option'); + for (let item of items) { + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + + it.each(['single', 'multiple'])('should support links with selectionMode="%s"', function (selectionMode) { + let {getAllByRole} = render( + + + One + Two + + + ); + + let items = getAllByRole('option'); + for (let item of items) { + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + expect(items[0]).not.toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + trigger(items[1]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://adobe.com/'); + expect(items[1]).not.toHaveAttribute('aria-selected', 'true'); + }); + }); + }); }); diff --git a/packages/@react-spectrum/menu/src/Menu.tsx b/packages/@react-spectrum/menu/src/Menu.tsx index 47d984decc3..4180ecacf6b 100644 --- a/packages/@react-spectrum/menu/src/Menu.tsx +++ b/packages/@react-spectrum/menu/src/Menu.tsx @@ -23,7 +23,7 @@ import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {useMenu} from '@react-aria/menu'; import {useTreeState} from '@react-stately/tree'; -function Menu(props: SpectrumMenuProps, ref: DOMRef) { +function Menu(props: SpectrumMenuProps, ref: DOMRef) { let contextProps = useContext(MenuContext); let completeProps = { ...mergeProps(contextProps, props) @@ -40,7 +40,7 @@ function Menu(props: SpectrumMenuProps, ref: DOMRef 0}>
-
    (props: SpectrumMenuProps, ref: DOMRef +
@@ -89,5 +89,5 @@ function Menu(props: SpectrumMenuProps, ref: DOMRef(props: SpectrumMenuProps & {ref?: DOMRef}) => ReactElement; +const _Menu = React.forwardRef(Menu) as (props: SpectrumMenuProps & {ref?: DOMRef}) => ReactElement; export {_Menu as Menu}; diff --git a/packages/@react-spectrum/menu/src/MenuItem.tsx b/packages/@react-spectrum/menu/src/MenuItem.tsx index 6a8cd0553e5..f8d08fdaa8d 100644 --- a/packages/@react-spectrum/menu/src/MenuItem.tsx +++ b/packages/@react-spectrum/menu/src/MenuItem.tsx @@ -52,7 +52,8 @@ export function MenuItem(props: MenuItemProps) { isUnavailable = menuDialogContext.isUnavailable; } - let domProps = filterDOMProps(item.props); + let ElementType: React.ElementType = item.props.href ? 'a' : 'div'; + let domProps = filterDOMProps(item.props, {isLink: !!item.props.href}); let { closeOnSelect @@ -66,7 +67,7 @@ export function MenuItem(props: MenuItemProps) { let isSelected = state.selectionManager.isSelected(key); let isDisabled = state.disabledKeys.has(key); - let itemref = useRef(null); + let itemref = useRef(null); let ref = useObjectRef(useMemo(() => mergeRefs(itemref, triggerRef), [itemref, triggerRef])); let { @@ -101,7 +102,7 @@ export function MenuItem(props: MenuItemProps) { return ( -
  • (props: MenuItemProps) { -
  • +
    ); } diff --git a/packages/@react-spectrum/menu/src/MenuSection.tsx b/packages/@react-spectrum/menu/src/MenuSection.tsx index 730fd8bc0ec..bd010f34468 100644 --- a/packages/@react-spectrum/menu/src/MenuSection.tsx +++ b/packages/@react-spectrum/menu/src/MenuSection.tsx @@ -35,20 +35,20 @@ export function MenuSection(props: MenuSectionProps) { }); let {separatorProps} = useSeparator({ - elementType: 'li' + elementType: 'div' }); return ( {item.key !== state.collection.getFirstKey() && -
  • } -
  • +
    {item.rendered && (props: MenuSectionProps) { {item.rendered} } -
      (props: MenuSectionProps) { return item; })} -
    -
  • +
    +
    ); } diff --git a/packages/@react-spectrum/menu/src/MenuTrigger.tsx b/packages/@react-spectrum/menu/src/MenuTrigger.tsx index 4e8f664acd5..cb2298f391a 100644 --- a/packages/@react-spectrum/menu/src/MenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/MenuTrigger.tsx @@ -26,7 +26,7 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef) let triggerRef = useRef(); let domRef = useDOMRef(ref); let menuTriggerRef = domRef || triggerRef; - let menuRef = useRef(); + let menuRef = useRef(); let { children, align = 'start', diff --git a/packages/@react-spectrum/menu/src/context.ts b/packages/@react-spectrum/menu/src/context.ts index a197e55a8f3..75a0adc5c0d 100644 --- a/packages/@react-spectrum/menu/src/context.ts +++ b/packages/@react-spectrum/menu/src/context.ts @@ -20,7 +20,7 @@ export interface MenuContextValue extends Omit, 'aut closeOnSelect?: boolean, shouldFocusWrap?: boolean, autoFocus?: boolean | FocusStrategy, - ref?: MutableRefObject, + ref?: MutableRefObject, state?: MenuTriggerState } @@ -32,7 +32,7 @@ export function useMenuContext(): MenuContextValue { export interface MenuDialogContextValue { isUnavailable?: boolean, - triggerRef?: MutableRefObject + triggerRef?: MutableRefObject } export const MenuDialogContext = React.createContext(undefined); @@ -44,7 +44,7 @@ export function useMenuDialogContext(): MenuDialogContextValue { export interface MenuStateContextValue { state?: TreeState, container?: RefObject, - menu?: RefObject + menu?: RefObject } export const MenuStateContext = React.createContext>({}); diff --git a/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx b/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx index 41ff57b63fe..d00c2fff661 100644 --- a/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx +++ b/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx @@ -830,3 +830,26 @@ function MenuWithUnavailableSometimes(props) { ); } + +export const MenuWithLinks = (props) => + render( + + Adobe + Google + Apple + + ); + +MenuWithLinks.story = { + args: { + selectionMode: 'none' + }, + argTypes: { + selectionMode: { + control: { + type: 'inline-radio', + options: ['none', 'single', 'multiple'] + } + } + } +}; diff --git a/packages/@react-spectrum/menu/test/Menu.test.js b/packages/@react-spectrum/menu/test/Menu.test.js index 4d8143eb561..01b6fb19f66 100644 --- a/packages/@react-spectrum/menu/test/Menu.test.js +++ b/packages/@react-spectrum/menu/test/Menu.test.js @@ -735,4 +735,47 @@ describe('Menu', function () { let menu = tree.getByRole('menu'); expect(menu).toHaveAttribute('data-testid', 'test'); }); + + describe('supports links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + it.each(['none', 'single', 'multiple'])('with selectionMode = %s', function (selectionMode) { + let onAction = jest.fn(); + let onSelectionChange = jest.fn(); + let tree = render( + + + One + Two + + + ); + + let role = { + none: 'menuitem', + single: 'menuitemradio', + multiple: 'menuitemcheckbox' + }[selectionMode]; + let items = tree.getAllByRole(role); + expect(items).toHaveLength(2); + expect(items[0].tagName).toBe('A'); + expect(items[0]).toHaveAttribute('href', 'https://google.com'); + expect(items[1].tagName).toBe('A'); + expect(items[1]).toHaveAttribute('href', 'https://adobe.com'); + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick); + + if (type === 'mouse') { + triggerPress(items[1]); + } else { + fireEvent.keyDown(items[1], {key: 'Enter'}); + fireEvent.keyUp(items[1], {key: 'Enter'}); + } + expect(onAction).toHaveBeenCalledTimes(1); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledTimes(1); + window.removeEventListener('click', onClick); + }); + }); + }); }); diff --git a/packages/@react-spectrum/picker/stories/Picker.stories.tsx b/packages/@react-spectrum/picker/stories/Picker.stories.tsx index 88b2d8762ca..b26561b695b 100644 --- a/packages/@react-spectrum/picker/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/picker/stories/Picker.stories.tsx @@ -352,6 +352,16 @@ export const Scrolling: ScrollingStory = { name: 'scrolling container' }; +export const Links: PickerStory = { + render: (args) => ( + + Foo + Bar + Google + + ) +}; + function DefaultPicker(props: SpectrumPickerProps) { return ( diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 98ca2b1a603..43c912915d5 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -2233,4 +2233,44 @@ describe('Picker', function () { expect(input).toHaveValue('one'); }); }); + + describe('links', () => { + it.each(['mouse', 'keyboard'])('supports links on items with %s', async (type) => { + let tree = render( + + + One + Two + + + ); + + let button = tree.getByRole('button'); + triggerPress(button); + act(() => { + jest.runAllTimers(); + }); + + let listbox = tree.getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + expect(items[0].tagName).toBe('A'); + expect(items[0]).toHaveAttribute('href', 'https://google.com'); + expect(items[1].tagName).toBe('A'); + expect(items[1]).toHaveAttribute('href', 'https://adobe.com'); + + if (type === 'mouse') { + triggerPress(items[0]); + } else { + fireEvent.keyDown(items[0], {key: 'Enter'}); + fireEvent.keyUp(items[0], {key: 'Enter'}); + } + act(() => { + jest.runAllTimers(); + }); + + expect(button).toHaveTextContent('Select an option…'); + expect(listbox).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 6cae547e086..96b65549fb1 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -1846,6 +1846,40 @@ export const TypeaheadWithDialog: TableStory = { ) }; +export const Links = (args) => { + return ( + + + Name + URL + Date added + + + + Adobe + https://adobe.com/ + January 28, 2023 + + + Google + https://google.com/ + April 5, 2023 + + + Apple + https://apple.com/ + June 5, 2023 + + + New York Times + https://nytimes.com/ + July 12, 2023 + + + + ); +}; + export const ColumnHeaderFocusRingTable = { render: () => , storyName: 'column header focus after loading', diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index 00bc1b79cb6..9c8aedbc959 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -4630,6 +4630,158 @@ export let tableTests = () => { expect(table).toHaveAttribute('tabIndex', '0'); }); }); + + describe('links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + let trigger = async (item, key = 'Enter') => { + if (type === 'mouse') { + await user.click(item); + } else { + fireEvent.keyDown(item, {key}); + fireEvent.keyUp(item, {key}); + } + }; + + it('should support links with selectionMode="none"', async function () { + let {getAllByRole} = render( + + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + + it.each(['single', 'multiple'])('should support links with selectionStyle="checkbox" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + + await user.click(within(items[0]).getByRole('checkbox')); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick); + await trigger(items[1], ' '); + expect(onClick).not.toHaveBeenCalled(); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); + document.removeEventListener('click', onClick); + }); + + it.each(['single', 'multiple'])('should support links with selectionStyle="highlight" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick); + await trigger(items[0], ' '); + expect(onClick).not.toHaveBeenCalled(); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + document.removeEventListener('click', onClick); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + if (type === 'mouse') { + await user.dblClick(items[0], {pointerType: 'mouse'}); + } else { + fireEvent.keyDown(items[0], {key: 'Enter'}); + fireEvent.keyUp(items[0], {key: 'Enter'}); + } + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + }); + }); }; describe('TableView', tableTests); diff --git a/packages/@react-spectrum/tabs/src/Tabs.tsx b/packages/@react-spectrum/tabs/src/Tabs.tsx index a1c69fc6fe1..a0911e6b993 100644 --- a/packages/@react-spectrum/tabs/src/Tabs.tsx +++ b/packages/@react-spectrum/tabs/src/Tabs.tsx @@ -160,18 +160,19 @@ function Tab(props: TabProps) { let {item, state} = props; let {key, rendered} = item; - let ref = useRef(); + let ref = useRef(); let {tabProps, isSelected, isDisabled} = useTab({key}, state, ref); let {hoverProps, isHovered} = useHover({ ...props }); - let domProps = filterDOMProps(item.props); + let ElementType: React.ElementType = item.props.href ? 'a' : 'div'; + let domProps = filterDOMProps(item.props as any, {isLink: !!item.props.href}); delete domProps.id; return ( -
    (props: TabProps) { ? {rendered} : rendered} -
    +
    ); } @@ -402,12 +403,7 @@ function TabPicker(props: TabPickerProps) { setPickerNode(node.current); }, [ref]); - let items = [...state.collection].map((item) => ({ - rendered: item.rendered, - textValue: item.textValue, - id: item.key - })); - + let items = [...state.collection]; let pickerProps = { 'aria-labelledby': ariaLabeledBy, 'aria-label': ariaLabel @@ -453,7 +449,7 @@ function TabPicker(props: TabPickerProps) { disabledKeys={state.disabledKeys} onSelectionChange={state.setSelectedKey} UNSAFE_className={classNames(styles, 'spectrum-Tabs-picker')}> - {item => {item.rendered}} + {item => {item.rendered}}
    {pickerNode && } diff --git a/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx b/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx index 7d8e5828557..5e674103425 100644 --- a/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx +++ b/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx @@ -20,6 +20,7 @@ import Dashboard from '@spectrum-icons/workflow/Dashboard'; import {Item, TabList, TabPanels, Tabs} from '..'; import {Picker} from '@react-spectrum/picker'; import React, {ReactNode, useState} from 'react'; +import {RouterProvider} from '@react-aria/utils'; import {SpectrumTabsProps} from '@react-types/tabs'; import {TextField} from '@react-spectrum/textfield'; @@ -369,6 +370,37 @@ ChangingSelectionProgramatically.story = { name: 'changing selection programatically' }; +export const Links = (args) => { + let [url, setUrl] = useState('/one'); + + return ( + + + + Tab 1 + Tab 2 + Tab 3 + Tab 4 + Tab 5 + + + Foo + Bar + Tab 3 + Tab 4 + Tab 5 + + + + ); +}; + +Links.story = { + args: { + collapsed: false + } +}; + function render(props = {}) { return ( ); + + let tabs = getAllByRole('tab'); + expect(tabs[0].tagName).toBe('A'); + expect(tabs[0]).toHaveAttribute('href', '/one'); + expect(tabs[1].tagName).toBe('A'); + expect(tabs[1]).toHaveAttribute('href', '/two'); + expect(tabs[2].tagName).toBe('A'); + expect(tabs[2]).toHaveAttribute('href', '/three'); + + expect(tabs[0]).toHaveAttribute('aria-selected', 'true'); + triggerPress(tabs[1]); + expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + + fireEvent.keyDown(tabs[1], {key: 'ArrowRight'}); + expect(tabs[2]).toHaveAttribute('aria-selected', 'true'); + }); }); diff --git a/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx b/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx index 067691f6aad..aee507565e1 100644 --- a/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx +++ b/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx @@ -280,3 +280,13 @@ function OnRemoveExample(props) { ); } + +export const Links: TagGroupStory = { + render: (args) => ( + + Adobe + Google + Apple + + ) +}; diff --git a/packages/@react-spectrum/tag/test/TagGroup.test.js b/packages/@react-spectrum/tag/test/TagGroup.test.js index b912480648c..43b8bf30f1e 100644 --- a/packages/@react-spectrum/tag/test/TagGroup.test.js +++ b/packages/@react-spectrum/tag/test/TagGroup.test.js @@ -731,4 +731,28 @@ describe('TagGroup', function () { expect(tags[0]).toHaveAttribute('data-foo', 'one'); expect(tags[1]).toHaveAttribute('data-foo', 'two'); }); + + it('should support links', function () { + let {getAllByRole} = render( + + + One + Two + + + ); + + let tags = getAllByRole('row'); + for (let tag of tags) { + expect(tag.tagName).not.toBe('A'); + expect(tag).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + triggerPress(tags[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); }); diff --git a/packages/@react-stately/list/src/useSingleSelectListState.ts b/packages/@react-stately/list/src/useSingleSelectListState.ts index 5c82e374663..8bf97d30fd5 100644 --- a/packages/@react-stately/list/src/useSingleSelectListState.ts +++ b/packages/@react-stately/list/src/useSingleSelectListState.ts @@ -47,7 +47,7 @@ export function useSingleSelectListState(props: SingleSelectLi allowDuplicateSelectionEvents: true, selectedKeys, onSelectionChange: (keys: Set) => { - let key = keys.values().next().value; + let key = keys.values().next().value ?? null; // Always fire onSelectionChange, even if the key is the same // as the current key (useControlledState does not). diff --git a/packages/@react-stately/selection/src/SelectionManager.ts b/packages/@react-stately/selection/src/SelectionManager.ts index 4b8cee9f4b6..5214db60fe9 100644 --- a/packages/@react-stately/selection/src/SelectionManager.ts +++ b/packages/@react-stately/selection/src/SelectionManager.ts @@ -491,4 +491,8 @@ export class SelectionManager implements MultipleSelectionManager { isDisabled(key: Key) { return this.state.disabledKeys.has(key) && this.state.disabledBehavior === 'all'; } + + isLink(key: Key) { + return !!this.collection.getItem(key)?.props?.href; + } } diff --git a/packages/@react-stately/selection/src/types.ts b/packages/@react-stately/selection/src/types.ts index 303153363fb..5bc9185c5d6 100644 --- a/packages/@react-stately/selection/src/types.ts +++ b/packages/@react-stately/selection/src/types.ts @@ -103,5 +103,7 @@ export interface MultipleSelectionManager extends FocusState { /** Returns whether the given key is non-interactive, i.e. both selection and actions are disabled. */ isDisabled(key: Key): boolean, /** Sets the selection behavior for the collection. */ - setSelectionBehavior(selectionBehavior: SelectionBehavior): void + setSelectionBehavior(selectionBehavior: SelectionBehavior): void, + /** Returns whether the given key is a hyperlink. */ + isLink(key: Key): boolean } diff --git a/packages/@react-types/breadcrumbs/src/index.d.ts b/packages/@react-types/breadcrumbs/src/index.d.ts index c6dc6012cd5..178e80b65a4 100644 --- a/packages/@react-types/breadcrumbs/src/index.d.ts +++ b/packages/@react-types/breadcrumbs/src/index.d.ts @@ -10,11 +10,11 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMProps, ItemProps, StyleProps} from '@react-types/shared'; +import {AriaLabelingProps, DOMProps, ItemProps, LinkDOMProps, StyleProps} from '@react-types/shared'; import {AriaLinkProps} from '@react-types/link'; import {Key, ReactElement, ReactNode} from 'react'; -export interface BreadcrumbItemProps extends AriaLinkProps { +export interface BreadcrumbItemProps extends AriaLinkProps, LinkDOMProps { /** Whether the breadcrumb item represents the current page. */ isCurrent?: boolean, /** diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index 4719cf33139..188f4193be2 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -11,8 +11,9 @@ */ import {Key, ReactElement, ReactNode} from 'react'; +import {LinkDOMProps} from './dom'; -export interface ItemProps { +export interface ItemProps extends LinkDOMProps { /** Rendered contents of the item or child items. */ children: ReactNode, /** Rendered contents of the item if `children` contains child items. */ diff --git a/packages/@react-types/shared/src/dom.d.ts b/packages/@react-types/shared/src/dom.d.ts index f68a3c12879..b52b9dabc42 100644 --- a/packages/@react-types/shared/src/dom.d.ts +++ b/packages/@react-types/shared/src/dom.d.ts @@ -17,6 +17,8 @@ import { CompositionEventHandler, CSSProperties, FormEventHandler, + HTMLAttributeAnchorTarget, + HTMLAttributeReferrerPolicy, DOMAttributes as ReactDOMAttributes, ReactEventHandler } from 'react'; @@ -167,6 +169,22 @@ export interface TextInputDOMProps extends DOMProps, InputDOMProps, TextInputDOM inputMode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search' } +// Make sure to update filterDOMProps.ts when updating this. +export interface LinkDOMProps { + /** A URL to link to. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href). */ + href?: string, + /** The target window for the link. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target). */ + target?: HTMLAttributeAnchorTarget, + /** The relationship between the linked resource and the current page. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel). */ + rel?: string, + /** Causes the browser to download the linked URL. A string may be provided to suggest a file name. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download). */ + download?: boolean | string, + /** A space-separated list of URLs to ping when the link is followed. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#ping). */ + ping?: string, + /** How much of the referrer to send when following the link. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#referrerpolicy). */ + referrerPolicy?: HTMLAttributeReferrerPolicy +} + /** Any focusable element, including both HTML and SVG elements. */ export interface FocusableElement extends Element, HTMLOrSVGElement {} diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index abd3d552be4..d7373928c95 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, AsyncLoadable, DOMProps, LoadingState, MultipleSelection, Sortable, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; +import {AriaLabelingProps, AsyncLoadable, DOMProps, LinkDOMProps, LoadingState, MultipleSelection, Sortable, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; import {GridCollection, GridNode} from '@react-types/grid'; import {Key, ReactElement, ReactNode} from 'react'; @@ -132,7 +132,7 @@ export interface TableBodyProps extends Omit { loadingState?: LoadingState } -export interface RowProps { +export interface RowProps extends LinkDOMProps { /** * A list of child item objects used when dynamically rendering row children. Requires the feature flag to be * enabled along with UNSTABLE_allowsExpandableRows, see https://react-spectrum.adobe.com/react-spectrum/TableView.html#expandable-rows. diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 448c2e265c9..9ed7dc87c93 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {CollectionBase} from '@react-types/shared'; +import {CollectionBase, LinkDOMProps} from '@react-types/shared'; import {createPortal} from 'react-dom'; import {forwardRefType, RenderProps, StyleProps} from './utils'; import {Collection as ICollection, Node, SelectionBehavior, SelectionMode, ItemProps as SharedItemProps, SectionProps as SharedSectionProps} from 'react-stately'; @@ -876,7 +876,7 @@ export function useSSRCollectionNode(Type: string, props: obj return {children}; } -export interface ItemProps extends Omit, 'children'>, RenderProps { +export interface ItemProps extends Omit, 'children'>, RenderProps, LinkDOMProps { /** The unique id of the item. */ id?: Key, /** The object value that this item represents. When using dynamic collections, this is set automatically. */ diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index d3470e64c8d..050a0a6d625 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -344,7 +344,7 @@ interface OptionProps { } function Option({item}: OptionProps) { - let ref = useObjectRef(item.props.ref); + let ref = useObjectRef(item.props.ref); let {state, shouldFocusOnHover, dragAndDropHooks, dragState, dropState} = useContext(InternalListBoxContext)!; let {optionProps, labelProps, descriptionProps, ...states} = useOption( {key: item.key}, @@ -399,13 +399,17 @@ function Option({item}: OptionProps) { } }, [item.textValue]); + let ElementType: React.ElementType = props.href ? 'a' : 'div'; + let DOMProps = filterDOMProps(props as any, {isLink: !!props.href}); + delete DOMProps.id; + return ( <> {dragAndDropHooks?.useDropIndicator && renderDropIndicator({type: 'item', key: item.key, dropPosition: 'before'}) } -
    ({item}: OptionProps) { ]}> {renderProps.children} -
    + {dragAndDropHooks?.useDropIndicator && state.collection.getKeyAfter(item.key) == null && renderDropIndicator({type: 'item', key: item.key, dropPosition: 'after'}) } diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 2d59e69a7d9..d4f327ca434 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -175,7 +175,7 @@ interface MenuItemProps { function MenuItem({item}: MenuItemProps) { let state = useContext(InternalMenuContext)!; - let ref = useObjectRef(item.props.ref); + let ref = useObjectRef(item.props.ref); let {menuItemProps, labelProps, descriptionProps, keyboardShortcutProps, ...states} = useMenuItem({key: item.key}, state, ref); let props: ItemProps = item.props; @@ -194,11 +194,12 @@ function MenuItem({item}: MenuItemProps) { } }); - let DOMProps = filterDOMProps(props as any); + let ElementType: React.ElementType = props.href ? 'a' : 'div'; + let DOMProps = filterDOMProps(props as any, {isLink: !!props.href}); delete DOMProps.id; return ( -
    ({item}: MenuItemProps) { ]}> {renderProps.children} -
    + ); } diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 407ba9282a8..9820ef176d6 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1,4 +1,4 @@ -import {AriaLabelingProps} from '@react-types/shared'; +import {AriaLabelingProps, LinkDOMProps} from '@react-types/shared'; import {BaseCollection, CollectionContext, CollectionProps, CollectionRendererContext, ItemRenderProps, NodeValue, useCachedChildren, useCollection, useCollectionChildren, useSSRCollectionNode} from './Collection'; import {buildHeaderRows, TableColumnResizeState} from '@react-stately/table'; import {ButtonContext} from './Button'; @@ -601,7 +601,7 @@ export {_TableBody as TableBody}; export interface RowRenderProps extends ItemRenderProps {} -export interface RowProps extends StyleRenderProps { +export interface RowProps extends StyleRenderProps, LinkDOMProps { id?: Key, /** A list of columns used when dynamically rendering cells. */ columns?: Iterable, diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index 5aed67a1d2c..5ee8b537938 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps} from '@react-types/shared'; +import {AriaLabelingProps, LinkDOMProps} from '@react-types/shared'; import {AriaTabListProps, AriaTabPanelProps, mergeProps, Orientation, useFocusRing, useHover, useTab, useTabList, useTabPanel} from 'react-aria'; import {BaseCollection, CollectionProps, Document, useCollectionDocument, useCollectionPortal, useSSRCollectionNode} from './Collection'; import {Collection, Node, TabListState, useTabListState} from 'react-stately'; @@ -42,7 +42,7 @@ export interface TabListRenderProps { state: TabListState } -export interface TabProps extends RenderProps, AriaLabelingProps { +export interface TabProps extends RenderProps, AriaLabelingProps, LinkDOMProps { id?: Key } @@ -269,7 +269,7 @@ export {_Tab as Tab}; function TabInner({item, state}: {item: Node, state: TabListState}) { let {key} = item; - let ref = useObjectRef(item.props.ref); + let ref = useObjectRef(item.props.ref); let {tabProps, isSelected, isDisabled, isPressed} = useTab({key}, state, ref); let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let {hoverProps, isHovered} = useHover({ @@ -291,11 +291,12 @@ function TabInner({item, state}: {item: Node, state: TabListState { +export interface TagProps extends RenderProps, LinkDOMProps { /** A unique id for the tag. */ id?: Key, /** diff --git a/packages/react-aria-components/stories/index.stories.tsx b/packages/react-aria-components/stories/index.stories.tsx index 2e4a2662e35..c008f7f6db9 100644 --- a/packages/react-aria-components/stories/index.stories.tsx +++ b/packages/react-aria-components/stories/index.stories.tsx @@ -11,11 +11,12 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Calendar, CalendarCell, CalendarGrid, Cell, Column, ColumnResizer, ComboBox, DateField, DateInput, DatePicker, DateRangePicker, DateSegment, Dialog, DialogTrigger, DropZone, FileTrigger, Group, Header, Heading, Input, Item, Keyboard, Label, Link, ListBox, ListBoxProps, Menu, MenuTrigger, Modal, ModalOverlay, NumberField, OverlayArrow, Popover, Radio, RadioGroup, RangeCalendar, ResizableTableContainer, Row, Section, Select, SelectValue, Separator, Slider, SliderOutput, SliderThumb, SliderTrack, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, TabsProps, Text, TimeField, Tooltip, TooltipTrigger, useDragAndDrop} from 'react-aria-components'; +import {Button, Calendar, CalendarCell, CalendarGrid, Cell, Column, ColumnResizer, ComboBox, DateField, DateInput, DatePicker, DateRangePicker, DateSegment, Dialog, DialogTrigger, DropZone, FileTrigger, Group, Header, Heading, Input, Item, Keyboard, Label, Link, ListBox, ListBoxProps, Menu, MenuTrigger, Modal, ModalOverlay, NumberField, OverlayArrow, Popover, Radio, RadioGroup, RangeCalendar, ResizableTableContainer, Row, Section, Select, SelectValue, Separator, Slider, SliderOutput, SliderThumb, SliderTrack, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, TabsProps, Tag, TagGroup, TagList, Text, TimeField, Tooltip, TooltipTrigger, useDragAndDrop} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; import clsx from 'clsx'; import {FocusRing, mergeProps, useButton, useClipboard, useDrag} from 'react-aria'; import React, {useRef, useState} from 'react'; +import {RouterProvider} from '@react-aria/utils'; import styles from '../example/index.css'; import {useListData} from 'react-stately'; @@ -37,6 +38,7 @@ export const ComboBoxExample = () => ( Foo Bar Baz + Google @@ -141,14 +143,36 @@ export const ComboBoxRenderPropsListBoxDynamic = () => ( ); -export const ListBoxExample = () => ( - +export const ListBoxExample = (args) => ( + Foo Bar Baz + Google ); +ListBoxExample.story = { + args: { + selectionMode: 'none', + selectionBehavior: 'toggle' + }, + argTypes: { + selectionMode: { + control: { + type: 'radio', + options: ['none', 'single', 'multiple'] + } + }, + selectionBehavior: { + control: { + type: 'radio', + options: ['toggle', 'replace'] + } + } + } +}; + // Known accessibility false positive: https://github.com/adobe/react-spectrum/wiki/Known-accessibility-false-positives#listbox // also has a aXe landmark error, not sure what it means export const ListBoxSections = () => ( @@ -186,6 +210,42 @@ export const ListBoxComplex = () => ( ); +export const TagGroupExample = (props) => ( + + + + News + Travel + Gaming + Shopping + + +); + +TagGroupExample.args = { + selectionMode: 'none', + selectionBehavior: 'toggle' +}; + +TagGroupExample.argTypes = { + selectionMode: { + control: { + type: 'inline-radio', + options: ['none', 'single', 'multiple'] + } + }, + selectionBehavior: { + control: { + type: 'inline-radio', + options: ['toggle', 'replace'] + } + } +}; + +function MyTag(props) { + return ({border: '1px solid gray', borderRadius: 4, padding: '0 4px', background: isSelected ? 'black' : '', color: isSelected ? 'white' : '', cursor: props.href ? 'pointer' : 'default'})} />; +} + export const SelectExample = () => ( @@ -220,6 +281,7 @@ export const SelectRenderProps = () => ( Foo Bar Baz + Google @@ -231,12 +293,13 @@ export const MenuExample = () => ( - +
    Section 1
    Foo Bar Baz + Google
    @@ -585,25 +648,30 @@ export const ModalExample = () => ( ); -// Has error with invalid aria-controls, bug documented here: https://github.com/adobe/react-spectrum/issues/4781#issuecomment-1641057070 -export const TabsExample = () => ( - - - Founding of Rome - Monarchy and Republic - Empire - - - Arma virumque cano, Troiae qui primus ab oris. - - - Senatus Populusque Romanus. - - - Alea jacta est. - - -); +export const TabsExample = () => { + let [url, setUrl] = useState('/FoR'); + + return ( + + + + Founding of Rome + Monarchy and Republic + Empire + + + Arma virumque cano, Troiae qui primus ab oris. + + + Senatus Populusque Romanus. + + + Alea jacta est. + + + + ); +}; // Has error with invalid aria-controls, bug documented here: https://github.com/adobe/react-spectrum/issues/4781#issuecomment-1641057070 export const TabsRenderProps = () => { diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 75437a679af..d7efe50e772 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -339,4 +339,108 @@ describe('GridList', () => { expect(onRootDrop).toHaveBeenCalledTimes(1); }); }); + + describe('links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + let trigger = async (item, key = 'Enter') => { + if (type === 'mouse') { + await user.click(item); + } else { + fireEvent.keyDown(item, {key}); + fireEvent.keyUp(item, {key}); + } + }; + + it('should support links with selectionMode="none"', async function () { + let {getAllByRole} = render( + + One + Two + + ); + + let items = getAllByRole('row'); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + + it.each(['single', 'multiple'])('should support links with selectionBehavior="toggle" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + One + Two + + ); + + let items = getAllByRole('row'); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + + await user.click(within(items[0]).getByRole('checkbox')); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[1], ' '); + expect(onClick).not.toHaveBeenCalled(); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it.each(['single', 'multiple'])('should support links with selectionBehavior="replace" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + One + Two + + ); + + let items = getAllByRole('row'); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + if (type === 'mouse') { + await user.click(items[0]); + } else { + fireEvent.keyDown(items[0], {key: ' '}); + fireEvent.keyUp(items[0], {key: ' '}); + } + expect(onClick).not.toHaveBeenCalled(); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + if (type === 'mouse') { + await user.dblClick(items[0], {pointerType: 'mouse'}); + } else { + fireEvent.keyDown(items[0], {key: 'Enter'}); + fireEvent.keyUp(items[0], {key: 'Enter'}); + } + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + }); + }); }); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 00e799a00a6..434ded4f4a1 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -642,4 +642,108 @@ describe('ListBox', () => { act(() => jest.runAllTimers()); }); }); + + describe('links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + let trigger = async (item) => { + if (type === 'mouse') { + await user.click(item); + } else { + fireEvent.keyDown(item, {key: 'Enter'}); + fireEvent.keyUp(item, {key: 'Enter'}); + } + }; + + it('should support links with selectionMode="none"', async function () { + let {getAllByRole} = render( + + One + Two + + ); + + let items = getAllByRole('option'); + for (let item of items) { + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + + it.each(['single', 'multiple'])('should support links with selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + One + Two + + ); + + let items = getAllByRole('option'); + for (let item of items) { + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + expect(items[0]).not.toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[1]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://adobe.com/'); + expect(items[1]).not.toHaveAttribute('aria-selected', 'true'); + }); + + it.each(['single', 'multiple'])('should support links with selectionMode="%s" selectionBehavior="replace"', async function (selectionMode) { + let {getAllByRole} = render( + + One + Two + + ); + + let items = getAllByRole('option'); + for (let item of items) { + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + if (type === 'mouse') { + await user.click(items[0]); + } else { + fireEvent.keyDown(items[0], {key: ' '}); + fireEvent.keyUp(items[0], {key: ' '}); + } + expect(onClick).not.toHaveBeenCalled(); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + if (type === 'mouse') { + await user.dblClick(items[0], {pointerType: 'mouse'}); + } else { + fireEvent.keyDown(items[0], {key: 'Enter'}); + fireEvent.keyUp(items[0], {key: 'Enter'}); + } + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + }); + }); }); diff --git a/packages/react-aria-components/test/Menu.test.js b/packages/react-aria-components/test/Menu.test.js index f7c042d8dfb..a4b4ed36c05 100644 --- a/packages/react-aria-components/test/Menu.test.js +++ b/packages/react-aria-components/test/Menu.test.js @@ -266,4 +266,45 @@ describe('Menu', () => { await user.click(getAllByRole('menuitem')[1]); expect(onAction).toHaveBeenLastCalledWith('rename'); }); + + describe('supports links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + it.each(['none', 'single', 'multiple'])('with selectionMode = %s', async function (selectionMode) { + let onAction = jest.fn(); + let onSelectionChange = jest.fn(); + let tree = render( + + One + Two + + ); + + let role = { + none: 'menuitem', + single: 'menuitemradio', + multiple: 'menuitemcheckbox' + }[selectionMode]; + let items = tree.getAllByRole(role); + expect(items).toHaveLength(2); + expect(items[0].tagName).toBe('A'); + expect(items[0]).toHaveAttribute('href', 'https://google.com'); + expect(items[1].tagName).toBe('A'); + expect(items[1]).toHaveAttribute('href', 'https://adobe.com'); + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick); + + if (type === 'mouse') { + await user.click(items[1]); + } else { + fireEvent.keyDown(items[1], {key: 'Enter'}); + fireEvent.keyUp(items[1], {key: 'Enter'}); + } + expect(onAction).toHaveBeenCalledTimes(1); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledTimes(1); + document.removeEventListener('click', onClick); + }); + }); + }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index c3854123cdb..7b4c5a5c55b 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -834,4 +834,153 @@ describe('Table', () => { ); } }); + + describe('links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + let trigger = async (item, key = 'Enter') => { + if (type === 'mouse') { + await user.click(item); + } else { + fireEvent.keyDown(item, {key}); + fireEvent.keyUp(item, {key}); + } + }; + + it('should support links with selectionMode="none"', async function () { + let {getAllByRole} = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + +
    + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + + it.each(['single', 'multiple'])('should support links with selectionBehavior="toggle" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + +
    + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + + await user.click(within(items[0]).getByRole('checkbox')); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + await trigger(items[1], ' '); + expect(onClick).not.toHaveBeenCalled(); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it.each(['single', 'multiple'])('should support links with selectionBehavior="replace" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + +
    + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + if (type === 'mouse') { + await user.click(items[0]); + } else { + fireEvent.keyDown(items[0], {key: ' '}); + fireEvent.keyUp(items[0], {key: ' '}); + } + expect(onClick).not.toHaveBeenCalled(); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick, {once: true}); + if (type === 'mouse') { + await user.dblClick(items[0], {pointerType: 'mouse'}); + } else { + fireEvent.keyDown(items[0], {key: 'Enter'}); + fireEvent.keyUp(items[0], {key: 'Enter'}); + } + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + }); + }); }); diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index 5ee79b61a74..7503b6bb190 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -13,6 +13,7 @@ import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils'; import React from 'react'; import {Tab, TabList, TabPanel, Tabs} from '../'; +import {TabsExample} from '../stories/index.stories'; import userEvent from '@testing-library/user-event'; let renderTabs = (tabsProps, tablistProps, tabProps, tabpanelProps) => render( @@ -293,4 +294,23 @@ describe('Tabs', () => { expect(onSelectionChange).toBeCalledTimes(1); }); + + it('should support tabs as links', async function () { + let {getAllByRole} = render(); + + let tabs = getAllByRole('tab'); + expect(tabs[0].tagName).toBe('A'); + expect(tabs[0]).toHaveAttribute('href', '/FoR'); + expect(tabs[1].tagName).toBe('A'); + expect(tabs[1]).toHaveAttribute('href', '/MaR'); + expect(tabs[2].tagName).toBe('A'); + expect(tabs[2]).toHaveAttribute('href', '/Emp'); + + expect(tabs[0]).toHaveAttribute('aria-selected', 'true'); + await user.click(tabs[1]); + expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + + fireEvent.keyDown(tabs[1], {key: 'ArrowRight'}); + expect(tabs[2]).toHaveAttribute('aria-selected', 'true'); + }); }); diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index 68153557da3..41311a84a84 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -242,4 +242,58 @@ describe('TagGroup', () => { expect(grid).toHaveAttribute('data-empty', 'true'); expect(grid).toHaveTextContent('No results'); }); + + describe('supports links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + let trigger = async item => { + if (type === 'mouse') { + await user.click(item); + } else { + fireEvent.keyDown(item, {key: 'Enter'}); + fireEvent.keyUp(item, {key: 'Enter'}); + } + }; + + it.each(['none', 'single', 'multiple'])('with selectionMode = %s', async function (selectionMode) { + let onSelectionChange = jest.fn(); + let tree = render( + + + + One + Two + Non link + + + ); + + let items = tree.getAllByRole('row'); + expect(items).toHaveLength(3); + expect(items[0].tagName).not.toBe('A'); + expect(items[0]).toHaveAttribute('data-href', 'https://google.com'); + expect(items[1].tagName).not.toBe('A'); + expect(items[1]).toHaveAttribute('data-href', 'https://adobe.com'); + expect(items[2]).not.toHaveAttribute('data-href'); + + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick); + + await trigger(items[1]); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledTimes(1); + + if (selectionMode !== 'none') { + await trigger(items[2]); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(items[2]).toHaveAttribute('aria-selected', 'true'); + + await trigger(items[1]); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(2); + + document.removeEventListener('click', onClick); + } + }); + }); + }); });