Skip to content

Commit d005520

Browse files
committed
🐛 fix: prevent accordion from stealing focus from content
1 parent b9820cc commit d005520

File tree

9 files changed

+75
-22
lines changed

9 files changed

+75
-22
lines changed

package.json

+3-6
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,14 @@
104104
"lint-staged": {
105105
"*.{md,mdx}": [
106106
"yarn prettier",
107-
"yarn eslint",
108-
"git add"
107+
"yarn eslint"
109108
],
110109
"*.{js,json,ts,tsx}": [
111110
"yarn prettier",
112-
"yarn eslint",
113-
"git add"
111+
"yarn eslint"
114112
],
115113
"*.css": [
116-
"yarn stylelint",
117-
"git add"
114+
"yarn stylelint"
118115
]
119116
},
120117
"publishConfig": {

src/components/InteractiveGroup/useInteractiveGroupItem.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useScrollIntoView, UseScrollIntoViewProps } from './../../hooks/useScro
33

44
export type UseInteractiveGroupItemProps<E extends HTMLElement> = Omit<UseScrollIntoViewProps<E>, 'behavior'> & {
55
scrollBehavior?: UseScrollIntoViewProps<E>['behavior'];
6-
moveBrowserFocus?: boolean;
6+
moveBrowserFocus?: boolean | ((props: { ref: React.MutableRefObject<E | null>; focused?: boolean }) => boolean);
77
};
88

99
/**
@@ -20,8 +20,13 @@ export function useInteractiveGroupItem<E extends HTMLElement>({
2020
useScrollIntoView<E>({ behavior: scrollBehavior, ref, focused });
2121

2222
useEffect(() => {
23-
if (moveBrowserFocus && focused && document.activeElement !== ref.current) {
24-
ref.current?.focus();
23+
if (focused && document.activeElement !== ref.current) {
24+
const shouldMoveBrowserFocus =
25+
typeof moveBrowserFocus === 'function' ? moveBrowserFocus({ ref, focused }) : moveBrowserFocus;
26+
27+
if (shouldMoveBrowserFocus) {
28+
ref.current?.focus();
29+
}
2530
}
2631
}, [focused, moveBrowserFocus, ref]);
2732
}

src/compositions/Accordion/AccordionLabel.tsx

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cx } from '@emotion/css';
2-
import React, { useRef } from 'react';
2+
import React, { useCallback, useRef } from 'react';
33
import { MergeElementPropsWithoutRef, Orientation } from '../../types';
44
import { useInteractiveGroupItem } from '../../components/InteractiveGroup/useInteractiveGroupItem';
55
import { useListRegistryItem } from '../../components/ListRegistry/useListRegistryItem';
@@ -9,6 +9,7 @@ import { AccordionItemStateProps } from './types';
99
export type LocalAccordionLabelProps = AccordionItemStateProps & {
1010
children: React.ReactNode;
1111
className?: string;
12+
contentRef?: React.RefObject<HTMLDivElement>;
1213
flush?: boolean;
1314
iconOnly?: boolean;
1415
id: string;
@@ -31,6 +32,7 @@ export type AccordionLabelProps = MergeElementPropsWithoutRef<'div', LocalAccord
3132
export function AccordionLabel({
3233
children,
3334
className,
35+
contentRef,
3436
disabled = false,
3537
flush = false,
3638
focused = false,
@@ -42,7 +44,16 @@ export function AccordionLabel({
4244
...props
4345
}: AccordionLabelProps): JSX.Element | null {
4446
const ref = useRef<HTMLDivElement>(null!);
45-
useInteractiveGroupItem<HTMLDivElement>({ focused, ref, moveBrowserFocus: true, scrollBehavior: 'smooth' });
47+
48+
// don't move the browser focus if the focus is within the content
49+
const moveBrowserFocus = useCallback(() => {
50+
if (contentRef?.current?.contains(document.activeElement)) {
51+
return false;
52+
}
53+
return true;
54+
}, [contentRef]);
55+
56+
useInteractiveGroupItem<HTMLDivElement>({ focused, ref, moveBrowserFocus, scrollBehavior: 'smooth' });
4657
useListRegistryItem({ id, ref });
4758

4859
// wrap the content in a class so the content opacity can change without affecting the pseudo elements

src/compositions/Accordion/AccordionList.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export const AccordionList = React.forwardRef<HTMLDivElement, AccordionListProps
5454
): React.ReactElement<AccordionListProps> => {
5555
const ref = useRef<HTMLDivElement>(null);
5656
const accessible = useAccessibility();
57-
const { focused, handleBlur, handleFocus } = useDeepFocus<HTMLDivElement>(ref);
57+
const { focused, handleBlur, handleFocus, setFocused } = useDeepFocus<HTMLDivElement>(ref);
5858
const themeId = useThemeId(initThemeId);
5959
const variant = contrast ? 'contrast' : initVariant;
6060

@@ -113,6 +113,7 @@ export const AccordionList = React.forwardRef<HTMLDivElement, AccordionListProps
113113
key={id}
114114
orientation={orientation}
115115
parentRef={ref}
116+
setDeepFocus={setFocused}
116117
unstyled={unstyled}
117118
{...item}
118119
/>

src/compositions/Accordion/AccordionListItem.tsx

+33-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cx } from '@emotion/css';
2-
import React, { useCallback, useContext } from 'react';
2+
import React, { useCallback, useContext, useRef } from 'react';
33
import { useAccessibility } from '../../context/Accessibility';
44
import { useComponentId } from '../../hooks/useComponentId';
55
import {
@@ -21,6 +21,8 @@ export type AccordionListItemProps = React.HTMLAttributes<HTMLDivElement> &
2121
index: number;
2222
/** The parent ref is used to get its dimensions so the content can be rendered at the right size */
2323
parentRef: React.RefObject<HTMLDivElement>;
24+
/** Manually triggers the focused state of the useDeepFocus hook */
25+
setDeepFocus: React.Dispatch<React.SetStateAction<boolean>>;
2426
unstyled?: boolean;
2527
};
2628

@@ -48,12 +50,14 @@ export const AccordionListItem = React.forwardRef<HTMLDivElement, AccordionListI
4850
labelProps,
4951
orientation,
5052
parentRef,
53+
setDeepFocus,
5154
unstyled,
5255
},
5356
forwardedRef,
5457
): React.ReactElement<AccordionListItemProps> => {
5558
const accessible = useAccessibility();
5659
const { generateComponentId } = useComponentId(componentId);
60+
const contentRef = useRef<HTMLDivElement | null>(null);
5761

5862
const { handleItemClick, isSelected, focusedIndex, setFocused } =
5963
useContext<InteractiveGroupContextValue<string, HTMLDivElement, HTMLDivElement>>(InteractiveGroupContext);
@@ -78,6 +82,30 @@ export const AccordionListItem = React.forwardRef<HTMLDivElement, AccordionListI
7882
[id, setFocused],
7983
);
8084

85+
const handleContentFocus = useCallback<React.FocusEventHandler<HTMLDivElement>>(
86+
event => {
87+
event.stopPropagation();
88+
89+
// set this item as focused
90+
setFocused(id, { event });
91+
92+
/**
93+
* Set the entire accordion as having a focused state, which is
94+
* necessary because the event.stopPropagation() call here stops
95+
* the natural setting of this state by the useDeepFocus hook.
96+
* The reason we need to do all this manually is that if you
97+
* click into the expanded content item of an accordion while the
98+
* accordion itself doesn't have focus, if we don't stopPropagation
99+
* then the accordion steals focus from the item that was clicked on.
100+
* And if we do stop propagation without manually reconciling the
101+
* focused state then the accordion will not appear to be focused
102+
* when in fact it is.
103+
*/
104+
setDeepFocus?.(true);
105+
},
106+
[id, setFocused, setDeepFocus],
107+
);
108+
81109
const itemFocused = focusedIndex === index;
82110
const itemSelected = isSelected(id);
83111
const stateProps = { disabled, focused: focused && itemFocused, selected: itemSelected };
@@ -98,6 +126,7 @@ export const AccordionListItem = React.forwardRef<HTMLDivElement, AccordionListI
98126
aria-controls={generateComponentId(id, 'panel')}
99127
aria-disabled={disabled}
100128
aria-expanded={!!stateProps.selected}
129+
contentRef={contentRef}
101130
flush={flush}
102131
iconOnly={iconOnly}
103132
id={generateComponentId(id)}
@@ -117,8 +146,11 @@ export const AccordionListItem = React.forwardRef<HTMLDivElement, AccordionListI
117146
duration={duration}
118147
easing={easing}
119148
id={generateComponentId(id, 'panel')}
149+
// if something within the content gets the focus this prevents the accordion from stealing it
150+
onFocus={handleContentFocus}
120151
orientation={orientation}
121152
parentRef={parentRef}
153+
ref={contentRef}
122154
role="tabpanel"
123155
{...contentProps}
124156
{...stateProps}

src/compositions/Accordion/stories/Accordion.docs.mdx

+6-5
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ import { Accordion, AccordionContainer, AccordionList } from '../index';
166166
<Rhythm pl={5} pr={3} py={3}>
167167
<Paper bordered full color={focused ? 'accent' : 'primary'}>
168168
<Flex full alignItems="center" direction="row" justifyContent="space-between">
169-
<span>First</span>
169+
<span>First{focused ? ' is focused' : ''}</span>
170170
<IconButton
171171
color={!focused && 'neutral'}
172172
contrast={focused}
@@ -213,7 +213,7 @@ import { Accordion, AccordionContainer, AccordionList } from '../index';
213213
<Rhythm full pl={5} pr={3} py={3}>
214214
<Paper bordered full color={focused ? 'accent' : 'primary'}>
215215
<Flex full alignItems="center" direction="row" justifyContent="space-between">
216-
<span>Second</span>
216+
<span>Second{focused ? ' is focused' : ''}</span>
217217
<IconButton
218218
color={!focused && 'neutral'}
219219
contrast={focused}
@@ -260,7 +260,7 @@ import { Accordion, AccordionContainer, AccordionList } from '../index';
260260
<Rhythm pl={5} pr={3} py={3}>
261261
<Paper bordered full color={focused ? 'accent' : 'primary'}>
262262
<Flex full alignItems="center" direction="row" justifyContent="space-between">
263-
<span>Third</span>
263+
<span>Third{focused ? ' is focused' : ''}</span>
264264
<IconButton
265265
color={!focused && 'neutral'}
266266
contrast={focused}
@@ -307,7 +307,7 @@ import { Accordion, AccordionContainer, AccordionList } from '../index';
307307
<Rhythm pl={5} pr={3} py={3}>
308308
<Paper bordered full color={focused ? 'accent' : 'primary'}>
309309
<Flex full alignItems="center" direction="row" justifyContent="space-between">
310-
<span>Fourth</span>
310+
<span>Fourth{focused ? ' is focused' : ''}</span>
311311
<IconButton
312312
color={!focused && 'neutral'}
313313
contrast={focused}
@@ -354,7 +354,7 @@ import { Accordion, AccordionContainer, AccordionList } from '../index';
354354
ipsum rhoncus augue, ac luctus lacus lorem vel risus. Integer a ex interdum, egestas dui vitae,
355355
maximus sem. Praesent id egestas.
356356
</p>
357-
<input type="text" />
357+
<input style={{ height: 28 }} type="text" />
358358
</Rhythm>
359359
<IconButton
360360
as="div"
@@ -375,6 +375,7 @@ import { Accordion, AccordionContainer, AccordionList } from '../index';
375375
labelProps: { style: { outline: 'none' } },
376376
},
377377
]}
378+
maxSelect={-1}
378379
minSelect={0}
379380
>
380381
{({ componentId, items, orientation, ref, themeId, style, unstyled }) => (

src/compositions/Dropdown/DropdownWithTags.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import { Flex } from '../../components/Flex';
88
import { IconText } from '../../components/IconText';
99
import { generateInteractiveGroupActions } from '../../components/InteractiveGroup/generateInteractiveGroupActions';
1010
import { InteractiveGroupStateAction } from '../../components/InteractiveGroup/interactiveGroupActions';
11-
import { getInteractiveGroupInitialState, interactiveGroupReducer, InteractiveGroupState } from '../../components/InteractiveGroup/interactiveGroupReducer';
11+
import {
12+
getInteractiveGroupInitialState,
13+
interactiveGroupReducer,
14+
InteractiveGroupState,
15+
} from '../../components/InteractiveGroup/interactiveGroupReducer';
1216
import { Rhythm } from '../../components/Rhythm/Rhythm';
1317
import { Tag, TagGroup, TagGroupProps, TagProps, TagShape, TagSize, TagWeight } from '../../components/Tag';
1418
import { TypographyWithSvg } from '../../components/Typography';
1519
import { PartialDropdown, PartialDropdownHandles, PartialDropdownProps } from './PartialDropdown';
1620
import { DropdownOption } from './types';
1721

18-
1922
export type DropdownWithTagsOption = DropdownOption & {
2023
tagProps?: TagProps<'button'>;
2124
};

src/hooks/useDeepFocus.ts

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export type UseDeepFocusResponse<E> = {
2424
focused: boolean;
2525
handleBlur: React.FocusEventHandler<E>;
2626
handleFocus: React.FocusEventHandler<E>;
27+
/** This should only be used if the state is in fact focused but a child stopped the focus propagation before it could be set */
28+
setFocused: React.Dispatch<React.SetStateAction<boolean>>;
2729
};
2830

2931
/**
@@ -128,6 +130,7 @@ export function useDeepFocus<E extends HTMLElement>(
128130
draftState.focused = focused;
129131
draftState.handleBlur = handleBlur;
130132
draftState.handleFocus = handleFocus;
133+
draftState.setFocused = setFocused;
131134
});
132135
return previousResponse.current;
133136
}

src/utils/measureDomNode.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export const measureDomNode = (
4646
container.appendChild(content);
4747
appendTo.appendChild(container);
4848

49-
const height = container.clientHeight;
50-
const width = container.clientWidth;
49+
const height = Math.ceil(container.clientHeight);
50+
const width = Math.ceil(container.clientWidth);
5151

5252
container.parentNode?.removeChild(container);
5353
return { height, width };

0 commit comments

Comments
 (0)