Skip to content

Commit

Permalink
feat(useRole): allow item props (#2658)
Browse files Browse the repository at this point in the history
  • Loading branch information
atomiks committed Dec 11, 2023
1 parent d9be248 commit 7dc269c
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 68 deletions.
8 changes: 8 additions & 0 deletions .changeset/shy-cars-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@floating-ui/react': patch
---

feat(useRole): add `select` and `combobox` component roles and allow
dynamic/derivable item props based on `active` and `selected` states. Also adds
`menuitem` role for nested `menu` reference elements, and automatically adds an
`id` to the item props for the new component roles for virtual focus.
5 changes: 3 additions & 2 deletions .changeset/small-chairs-invite.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
'@floating-ui/react': patch
---

fix(useListNavigation): apply descendant prop on floating element only for
non-typeable combobox references
fix(useListNavigation): apply `aria-activedescendant` prop on floating element
only for non typeable-combobox reference elements. Fixes issues with Firefox
VoiceOver on Mac forcing DOM focus into the listbox.
7 changes: 3 additions & 4 deletions packages/react/src/components/FloatingFocusManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
guards: _guards = true,
initialFocus = 0,
returnFocus = true,
modal: originalModal = true,
modal = true,
visuallyHiddenDismiss = false,
closeOnFocusOut = true,
} = props;
Expand All @@ -122,7 +122,6 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
// start.
const isUntrappedTypeableCombobox =
isTypeableCombobox(domReference) && ignoreInitialFocus;
const modal = isUntrappedTypeableCombobox ? false : originalModal;

// Force the guards to be rendered if the `inert` attribute is not supported.
const guards = supportsInert() ? _guards : true;
Expand Down Expand Up @@ -318,7 +317,7 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
].filter((x): x is Element => x != null);

const cleanup =
originalModal || isUntrappedTypeableCombobox
modal || isUntrappedTypeableCombobox
? markOthers(insideElements, guards, !guards)
: markOthers(insideElements);

Expand All @@ -330,7 +329,7 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
disabled,
domReference,
floating,
originalModal,
modal,
orderRef,
portalContext,
isUntrappedTypeableCombobox,
Expand Down
42 changes: 35 additions & 7 deletions packages/react/src/hooks/useInteractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,51 @@ import * as React from 'react';

import type {ElementProps} from '../types';

function mergeProps(
userProps: React.HTMLProps<Element> | undefined,
const ACTIVE_KEY = 'active';
const SELECTED_KEY = 'selected';

export type ExtendedUserProps = {
[ACTIVE_KEY]?: boolean;
[SELECTED_KEY]?: boolean;
};

function mergeProps<Key extends keyof ElementProps>(
userProps: (React.HTMLProps<Element> & ExtendedUserProps) | undefined,
propsList: Array<ElementProps | void>,
elementKey: 'reference' | 'floating' | 'item',
elementKey: Key,
): Record<string, unknown> {
const map = new Map<string, Array<(...args: unknown[]) => void>>();
const isItem = elementKey === 'item';

let domUserProps = userProps;
if (isItem && userProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {[ACTIVE_KEY]: _, [SELECTED_KEY]: __, ...validProps} = userProps;
domUserProps = validProps;
}

return {
...(elementKey === 'floating' && {tabIndex: -1}),
...userProps,
...domUserProps,
...propsList
.map((value) => (value ? value[elementKey] : null))
.map((value) => {
const propsOrGetProps = value ? value[elementKey] : null;
if (typeof propsOrGetProps === 'function') {
return userProps ? propsOrGetProps(userProps) : null;
}
return propsOrGetProps;
})
.concat(userProps)
.reduce((acc: Record<string, unknown>, props) => {
if (!props) {
return acc;
}

Object.entries(props).forEach(([key, value]) => {
if (isItem && [ACTIVE_KEY, SELECTED_KEY].includes(key)) {
return;
}

if (key.indexOf('on') === 0) {
if (!map.has(key)) {
map.set(key, []);
Expand Down Expand Up @@ -72,8 +98,10 @@ export function useInteractions(propsList: Array<ElementProps | void> = []) {
);

const getItemProps = React.useCallback(
(userProps?: React.HTMLProps<HTMLElement>) =>
mergeProps(userProps, propsList, 'item'),
(
userProps?: Omit<React.HTMLProps<HTMLElement>, 'selected' | 'active'> &
ExtendedUserProps,
) => mergeProps(userProps, propsList, 'item'),
// Granularly check for `item` changes, because the `getItemProps` getter
// should be as referentially stable as possible since it may be passed as
// a prop to many components. All `item` key values must therefore be
Expand Down
85 changes: 63 additions & 22 deletions packages/react/src/hooks/useRole.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import * as React from 'react';

import {useFloatingParentNodeId} from '../components/FloatingTree';
import type {ElementProps, FloatingContext, ReferenceType} from '../types';
import {useId} from './useId';

type AriaRole =
| 'tooltip'
| 'dialog'
| 'alertdialog'
| 'menu'
| 'listbox'
| 'grid'
| 'tree';
type ComponentRole = 'select' | 'label' | 'combobox';

export interface UseRoleProps {
enabled?: boolean;
role?:
| 'tooltip'
| 'label'
| 'dialog'
| 'alertdialog'
| 'menu'
| 'listbox'
| 'grid'
| 'tree';
role?: AriaRole | ComponentRole;
}

const componentRoleToAriaRoleMap = new Map<
AriaRole | ComponentRole,
AriaRole | false
>([
['select', 'listbox'],
['combobox', 'listbox'],
['label', false],
]);

/**
* Adds base screen reader props to the reference and floating elements for a
* given floating element `role`.
Expand All @@ -28,17 +40,24 @@ export function useRole<RT extends ReferenceType = ReferenceType>(
const {open, floatingId} = context;
const {enabled = true, role = 'dialog'} = props;

const ariaRole = (componentRoleToAriaRoleMap.get(role) ?? role) as
| AriaRole
| false
| undefined;

const referenceId = useId();
const parentId = useFloatingParentNodeId();
const isNested = parentId != null;

return React.useMemo(() => {
if (!enabled) return {};

const floatingProps = {
id: floatingId,
...(role !== 'label' && {role}),
...(ariaRole && {role: ariaRole}),
};

if (role === 'tooltip' || role === 'label') {
if (ariaRole === 'tooltip' || role === 'label') {
return {
reference: {
[`aria-${role === 'label' ? 'labelledby' : 'describedby'}`]: open
Expand All @@ -52,21 +71,43 @@ export function useRole<RT extends ReferenceType = ReferenceType>(
return {
reference: {
'aria-expanded': open ? 'true' : 'false',
'aria-haspopup': role === 'alertdialog' ? 'dialog' : role,
'aria-haspopup': ariaRole === 'alertdialog' ? 'dialog' : ariaRole,
'aria-controls': open ? floatingId : undefined,
...(role === 'listbox' && {
role: 'combobox',
}),
...(role === 'menu' && {
id: referenceId,
}),
...(ariaRole === 'listbox' && {role: 'combobox'}),
...(ariaRole === 'menu' && {id: referenceId}),
...(ariaRole === 'menu' && isNested && {role: 'menuitem'}),
...(role === 'select' && {'aria-autocomplete': 'none'}),
...(role === 'combobox' && {'aria-autocomplete': 'list'}),
},
floating: {
...floatingProps,
...(role === 'menu' && {
'aria-labelledby': referenceId,
}),
...(ariaRole === 'menu' && {'aria-labelledby': referenceId}),
},
item({active, selected}) {
const commonProps = {
role: 'option',
...(active && {id: `${floatingId}-option`}),
};

// For `menu`, we are unable to tell if the item is a `menuitemradio`
// or `menuitemcheckbox`. For backwards-compatibility reasons, also
// avoid defaulting to `menuitem` as it may overwrite custom role props.
switch (role) {
case 'select':
return {
...commonProps,
'aria-selected': active && selected,
};
case 'combobox': {
return {
...commonProps,
...(active && {'aria-selected': true}),
};
}
}

return {};
},
};
}, [enabled, role, open, floatingId, referenceId]);
}, [enabled, role, ariaRole, open, floatingId, referenceId, isNested]);
}
6 changes: 5 additions & 1 deletion packages/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
} from '@floating-ui/react-dom';
import type * as React from 'react';

import type {ExtendedUserProps} from './hooks/useInteractions';

export * from '.';
export {FloatingArrowProps} from './components/FloatingArrow';
export {FloatingFocusManagerProps} from './components/FloatingFocusManager';
Expand Down Expand Up @@ -156,7 +158,9 @@ export interface FloatingTreeType<RT extends ReferenceType = ReferenceType> {
export interface ElementProps {
reference?: React.HTMLProps<Element>;
floating?: React.HTMLProps<HTMLElement>;
item?: React.HTMLProps<HTMLElement>;
item?:
| React.HTMLProps<HTMLElement>
| ((props: ExtendedUserProps) => React.HTMLProps<HTMLElement>);
}

export type ReferenceType = Element | VirtualElement;
Expand Down
6 changes: 5 additions & 1 deletion packages/react/test/unit/FloatingFocusManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1200,7 +1200,11 @@ test('untrapped combobox creates non-modal focus management', async () => {
/>
{isOpen && (
<FloatingPortal>
<FloatingFocusManager context={context} initialFocus={-1}>
<FloatingFocusManager
context={context}
initialFocus={-1}
modal={false}
>
<div
ref={refs.setFloating}
style={floatingStyles}
Expand Down
Loading

0 comments on commit 7dc269c

Please sign in to comment.