Skip to content

Commit 48af088

Browse files
committed
feat: Async focus control
1 parent aaa2a90 commit 48af088

20 files changed

+162
-44
lines changed

src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Map {
114114
"focus": [Function],
115115
},
116116
},
117+
"onMount": [Function],
117118
"slider": {
118119
"current": null,
119120
},
@@ -146,6 +147,7 @@ Map {
146147
"focus": [Function],
147148
},
148149
},
150+
"onMount": [Function],
149151
"slider": {
150152
"current": null,
151153
},
@@ -171,9 +173,10 @@ Map {
171173
"setToolbarHeight": [Function],
172174
"setToolbarState": [Function],
173175
"splitPanelAnimationDisabled": true,
174-
"splitPanelControlId": "split-panel41",
176+
"splitPanelControlId": "split-panel33",
175177
"splitPanelFocusControl": {
176178
"refs": {
179+
"onMount": [Function],
177180
"preferences": {
178181
"current": null,
179182
},
@@ -243,6 +246,7 @@ Map {
243246
"onToggle": [Function],
244247
"position": "bottom",
245248
"refs": {
249+
"onMount": [Function],
246250
"preferences": {
247251
"current": null,
248252
},
@@ -411,6 +415,7 @@ Map {
411415
"focus": [Function],
412416
},
413417
},
418+
"onMount": [Function],
414419
"slider": {
415420
"current": null,
416421
},
@@ -443,6 +448,7 @@ Map {
443448
"focus": [Function],
444449
},
445450
},
451+
"onMount": [Function],
446452
"slider": {
447453
"current": null,
448454
},
@@ -468,9 +474,10 @@ Map {
468474
"setToolbarHeight": [Function],
469475
"setToolbarState": [Function],
470476
"splitPanelAnimationDisabled": true,
471-
"splitPanelControlId": "split-panel41",
477+
"splitPanelControlId": "split-panel33",
472478
"splitPanelFocusControl": {
473479
"refs": {
480+
"onMount": [Function],
474481
"preferences": {
475482
"current": null,
476483
},
@@ -540,6 +547,7 @@ Map {
540547
"onToggle": [Function],
541548
"position": "bottom",
542549
"refs": {
550+
"onMount": [Function],
543551
"preferences": {
544552
"current": null,
545553
},
@@ -708,6 +716,7 @@ Map {
708716
"focus": [Function],
709717
},
710718
},
719+
"onMount": [Function],
711720
"slider": {
712721
"current": null,
713722
},
@@ -740,6 +749,7 @@ Map {
740749
"focus": [Function],
741750
},
742751
},
752+
"onMount": [Function],
743753
"slider": {
744754
"current": null,
745755
},
@@ -765,9 +775,10 @@ Map {
765775
"setToolbarHeight": [Function],
766776
"setToolbarState": [Function],
767777
"splitPanelAnimationDisabled": true,
768-
"splitPanelControlId": "split-panel41",
778+
"splitPanelControlId": "split-panel33",
769779
"splitPanelFocusControl": {
770780
"refs": {
781+
"onMount": [Function],
771782
"preferences": {
772783
"current": null,
773784
},
@@ -837,6 +848,7 @@ Map {
837848
"onToggle": [Function],
838849
"position": "bottom",
839850
"refs": {
851+
"onMount": [Function],
840852
"preferences": {
841853
"current": null,
842854
},
@@ -1005,6 +1017,7 @@ Map {
10051017
"focus": [Function],
10061018
},
10071019
},
1020+
"onMount": [Function],
10081021
"slider": {
10091022
"current": null,
10101023
},
@@ -1037,6 +1050,7 @@ Map {
10371050
"focus": [Function],
10381051
},
10391052
},
1053+
"onMount": [Function],
10401054
"slider": {
10411055
"current": null,
10421056
},
@@ -1062,9 +1076,10 @@ Map {
10621076
"setToolbarHeight": [Function],
10631077
"setToolbarState": [Function],
10641078
"splitPanelAnimationDisabled": true,
1065-
"splitPanelControlId": "split-panel41",
1079+
"splitPanelControlId": "split-panel33",
10661080
"splitPanelFocusControl": {
10671081
"refs": {
1082+
"onMount": [Function],
10681083
"preferences": {
10691084
"current": null,
10701085
},
@@ -1134,6 +1149,7 @@ Map {
11341149
"onToggle": [Function],
11351150
"position": "bottom",
11361151
"refs": {
1152+
"onMount": [Function],
11371153
"preferences": {
11381154
"current": null,
11391155
},

src/app-layout/__tests__/common.test.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,10 +283,16 @@ describeEachAppLayout(({ theme, size }) => {
283283
await waitForAppLayoutLoaded(wrapper);
284284

285285
findToggle(wrapper).click();
286-
expect(findClose(wrapper).getElement()).toBe(document.activeElement);
286+
287+
await waitFor(() => {
288+
expect(findClose(wrapper).getElement()).toHaveFocus();
289+
});
287290

288291
findClose(wrapper).click();
289-
expect(findToggle(wrapper).getElement()).toBe(document.activeElement);
292+
293+
await waitFor(() => {
294+
expect(findToggle(wrapper).getElement()).toHaveFocus();
295+
});
290296
});
291297

292298
test(`Should not render the drawer if ${hideProp} is set to true`, () => {

src/app-layout/__tests__/desktop.test.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,12 @@ describeEachAppLayout({ sizes: ['desktop'] }, ({ theme }) => {
181181
const { wrapper } = renderComponent(<AppLayout drawers={[{ ...testDrawer, resizable: true }]} />);
182182

183183
await waitFor(() => {
184-
wrapper.findDrawerTriggerById('security')!.click();
184+
expect(wrapper.findDrawerTriggerById('security')).toBeTruthy();
185+
});
186+
wrapper.findDrawerTriggerById('security')!.click();
187+
await waitFor(() => {
188+
expect(wrapper.findActiveDrawerResizeHandle()!.getElement()).toHaveFocus();
185189
});
186-
expect(wrapper.findActiveDrawerResizeHandle()!.getElement()).toHaveFocus();
187190
});
188191

189192
test('should change size via keyboard events on slider handle', async () => {

src/app-layout/__tests__/runtime-drawers.test.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -934,11 +934,13 @@ describe('toolbar mode only features', () => {
934934

935935
wrapper.findDrawerTriggerById('test-resizable')!.click();
936936

937-
expect(globalDrawersWrapper.findResizeHandleByActiveDrawerId('test-resizable')!.getElement()).toHaveFocus();
938-
expect(globalDrawersWrapper.findResizeHandleByActiveDrawerId('test-resizable')!.getElement()).toHaveAttribute(
939-
'aria-label',
940-
'drawer resize'
941-
);
937+
await waitFor(() => {
938+
expect(globalDrawersWrapper.findResizeHandleByActiveDrawerId('test-resizable')!.getElement()).toHaveFocus();
939+
expect(globalDrawersWrapper.findResizeHandleByActiveDrawerId('test-resizable')!.getElement()).toHaveAttribute(
940+
'aria-label',
941+
'drawer resize'
942+
);
943+
});
942944
});
943945

944946
test('close active global drawer by clicking on close button', async () => {
@@ -1162,11 +1164,16 @@ describe('toolbar mode only features', () => {
11621164

11631165
// globalDrawersWrapper.findDrawerById(drawerId)!.blur() does not trigger the blur event on the active drawer
11641166
fireEvent.blur(globalDrawersWrapper.findDrawerById(drawerId)!.getElement());
1165-
expect(getByTestId('trigger-button')).not.toHaveFocus();
1167+
1168+
await waitFor(() => {
1169+
expect(getByTestId('trigger-button')).not.toHaveFocus();
1170+
});
11661171

11671172
globalDrawersWrapper.findCloseButtonByActiveDrawerId(drawerId)!.click();
11681173

1169-
expect(getByTestId('trigger-button')).toHaveFocus();
1174+
await waitFor(() => {
1175+
expect(getByTestId('trigger-button')).toHaveFocus();
1176+
});
11701177
});
11711178

11721179
test('closes a drawer when closeDrawer is called (global drawer)', async () => {

src/app-layout/__tests__/split-panel-provider.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ describe.each(['bottom', 'side'] as const)('position=%s', position => {
4343
slider: { current: null },
4444
toggle: { current: null },
4545
preferences: { current: null },
46+
onMount: () => {},
4647
},
4748
reportHeaderHeight: () => {},
4849
reportSize: () => {},

src/app-layout/__tests__/split-panel.test.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,17 @@ describeEachAppLayout({ sizes: ['desktop'] }, ({ theme }) => {
139139
);
140140

141141
await waitFor(() => {
142-
wrapper.findSplitPanelOpenButton()!.click();
142+
expect(wrapper.findSplitPanelOpenButton()).toBeTruthy();
143143
});
144+
wrapper.findSplitPanelOpenButton()!.click();
144145
wrapper.findSplitPanel()!.findCloseButton()!.click();
145146
const button =
146147
position === 'side'
147148
? wrapper.findSplitPanelOpenButton()
148149
: wrapper.findSplitPanel()!.findByClassName(testUtilStyles['open-button']);
149-
expect(button!.getElement()).toHaveFocus();
150+
await waitFor(() => {
151+
expect(button!.getElement()).toHaveFocus();
152+
});
150153
});
151154

152155
test(`Moves focus to the slider when focusSplitPanel() is called`, async () => {
@@ -162,7 +165,12 @@ describeEachAppLayout({ sizes: ['desktop'] }, ({ theme }) => {
162165
);
163166

164167
await waitFor(() => {
165-
ref.current!.focusSplitPanel();
168+
expect(wrapper.findSplitPanel()).toBeTruthy();
169+
});
170+
171+
ref.current!.focusSplitPanel();
172+
173+
await waitFor(() => {
166174
expect(wrapper.findSplitPanel()!.findSlider()!.getElement()).toHaveFocus();
167175
});
168176
});

src/app-layout/drawer/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, { useRef } from 'react';
44
import clsx from 'clsx';
55

66
import { useContainerQuery } from '@cloudscape-design/component-toolkit';
7-
import { useDensityMode } from '@cloudscape-design/component-toolkit/internal';
7+
import { useDensityMode, useMergeRefs } from '@cloudscape-design/component-toolkit/internal';
88

99
import { getVisualContextClassname } from '../../internal/components/visual-context';
1010
import { AppLayoutProps } from '../interfaces';
@@ -77,6 +77,8 @@ export const Drawer = React.forwardRef(
7777
</TagName>
7878
);
7979

80+
const closeMergedRef = useMergeRefs(toggleRefs.close, toggleRefs.onMount);
81+
8082
return (
8183
<div
8284
ref={ref}
@@ -128,7 +130,7 @@ export const Drawer = React.forwardRef(
128130
>
129131
{!isMobile && isOpen && <div className={styles['resize-handle-wrapper']}>{resizeHandle}</div>}
130132
<CloseButton
131-
ref={toggleRefs.close}
133+
ref={closeMergedRef}
132134
className={closeClassName}
133135
ariaLabel={closeLabel}
134136
onClick={() => {

src/app-layout/drawer/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface DesktopDrawerProps {
1414
toggleRefs: {
1515
toggle: React.Ref<{ focus(): void }>;
1616
close: React.Ref<{ focus(): void }>;
17+
onMount: () => void;
1718
};
1819
width: number;
1920
topOffset: number | undefined;

src/app-layout/utils/defer.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
export class Deferred<T> {
4+
private readonly _promise: Promise<T>;
5+
private _resolve?: (value?: any) => void;
6+
private _reject?: (reason?: any) => void;
7+
8+
constructor() {
9+
this._promise = new Promise<T>((resolve, reject) => {
10+
this._resolve = resolve;
11+
this._reject = reject;
12+
});
13+
}
14+
15+
get promise(): Promise<T> {
16+
return this._promise;
17+
}
18+
19+
resolve = (value?: T | PromiseLike<T>): void => {
20+
this._resolve?.(value);
21+
};
22+
23+
reject = (reason?: any): void => {
24+
this._reject?.(reason);
25+
};
26+
}

src/app-layout/utils/use-focus-control.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// SPDX-License-Identifier: Apache-2.0
33
import { createRef, RefObject, useCallback, useEffect, useRef } from 'react';
44

5+
import { Deferred } from './defer';
6+
57
export interface Focusable {
68
focus(): void;
79
}
@@ -10,6 +12,8 @@ export interface FocusControlRefs {
1012
toggle: RefObject<Focusable>;
1113
close: RefObject<Focusable>;
1214
slider: RefObject<HTMLDivElement>;
15+
onMount?: (node: HTMLDivElement) => void;
16+
focusPromise?: any;
1317
}
1418

1519
export interface FocusControlState {
@@ -36,6 +40,10 @@ export function useMultipleFocusControl(
3640
toggle: createRef<Focusable>(),
3741
close: createRef<Focusable>(),
3842
slider: createRef<HTMLDivElement>(),
43+
onMount: () => {
44+
refs.current[drawerId].focusPromise?.resolve();
45+
},
46+
focusPromise: new Deferred(),
3947
};
4048
}
4149
});
@@ -83,7 +91,10 @@ export function useMultipleFocusControl(
8391
const shouldFocus = useRef(false);
8492

8593
useEffect(() => {
86-
doFocus(activeDrawersIds[0]);
94+
const drawerId = activeDrawersIds[0];
95+
refs.current[drawerId]?.focusPromise?.promise?.then(() => {
96+
doFocus(drawerId);
97+
});
8798
}, [activeDrawersIds, doFocus]);
8899

89100
return {
@@ -98,10 +109,14 @@ export function useFocusControl(
98109
restoreFocus = false,
99110
activeDrawerId?: string | null
100111
): FocusControlState {
112+
const focusPromise = useRef<Deferred<undefined>>(new Deferred());
101113
const refs = {
102114
toggle: useRef<Focusable>(null),
103115
close: useRef<Focusable>(null),
104116
slider: useRef<HTMLDivElement>(null),
117+
onMount: () => {
118+
focusPromise.current.resolve();
119+
},
105120
};
106121
const previousFocusedElement = useRef<HTMLElement>();
107122
const shouldFocus = useRef(false);
@@ -136,8 +151,12 @@ export function useFocusControl(
136151
}
137152
};
138153

139-
// eslint-disable-next-line react-hooks/exhaustive-deps
140-
useEffect(doFocus, [isOpen, activeDrawerId]);
154+
useEffect(() => {
155+
focusPromise.current.promise.then(() => {
156+
doFocus();
157+
});
158+
// eslint-disable-next-line react-hooks/exhaustive-deps
159+
}, [isOpen, activeDrawerId]);
141160

142161
const loseFocus = useCallback(() => {
143162
previousFocusedElement.current = undefined;

0 commit comments

Comments
 (0)