Skip to content

Commit

Permalink
feat: use tabster restorer API (microsoft#28530)
Browse files Browse the repository at this point in the history
* feat: use tabster restorer API

* custom restorer hook

* update patch

* update snapshots

* update patch

* fix enums

* update patch

* bump tabster

* update md

* add docs

* changefiles

* add stories prefix

* remove changes

* update documentation example

* rename useMenuTrigger

* pr fixes

* update docs

* add popover doc example
  • Loading branch information
ling1726 authored Jul 25, 2023
1 parent e7e522f commit c0c62f9
Show file tree
Hide file tree
Showing 37 changed files with 483 additions and 60 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: Export useRestoreFocusTarget and useRestoreFocusSource",
"packageName": "@fluentui/react-components",
"email": "lingfan.gao@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: Focus should restore to a DialogTrigger outside of a Dialog",
"packageName": "@fluentui/react-dialog",
"email": "lingfan.gao@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "refactor: Remove custom focus code in favour of useRestoreFocus hooks",
"packageName": "@fluentui/react-menu",
"email": "lingfan.gao@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: Implement useRestoreFocusSource and useRestoreFocusTarget based on the tabster restorer API",
"packageName": "@fluentui/react-tabster",
"email": "lingfan.gao@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,8 @@ import { useRadioGroupContextValue_unstable } from '@fluentui/react-radio';
import { useRadioGroupContextValues } from '@fluentui/react-radio';
import { useRadioGroupStyles_unstable } from '@fluentui/react-radio';
import { useRadioStyles_unstable } from '@fluentui/react-radio';
import { useRestoreFocusSource } from '@fluentui/react-tabster';
import { useRestoreFocusTarget } from '@fluentui/react-tabster';
import { useScrollbarWidth } from '@fluentui/react-utilities';
import { useSelect_unstable } from '@fluentui/react-select';
import { useSelectStyles_unstable } from '@fluentui/react-select';
Expand Down Expand Up @@ -3087,6 +3089,10 @@ export { useRadioGroupStyles_unstable }

export { useRadioStyles_unstable }

export { useRestoreFocusSource }

export { useRestoreFocusTarget }

export { useScrollbarWidth }

export { useSelect_unstable }
Expand Down
2 changes: 2 additions & 0 deletions packages/react-components/react-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export {
useModalAttributes,
useObservedElement,
useFocusObserved,
useRestoreFocusTarget,
useRestoreFocusSource,
} from '@fluentui/react-tabster';
export type {
CreateCustomFocusIndicatorStyleOptions,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from 'react';
import { ThumbLikeRegular, ThumbDislikeRegular } from '@fluentui/react-icons';
import {
useRestoreFocusSource,
useRestoreFocusTarget,
Button,
makeStyles,
Textarea,
Field,
} from '@fluentui/react-components';

const useStyles = makeStyles({
feedback: {
display: 'flex',
alignItems: 'center',
},

field: {
width: '300px',
},
});

export const Default = () => {
const styles = useStyles();
const restoreFocusSourceAttribute = useRestoreFocusSource();
const restoreFocusTargetAttribute = useRestoreFocusTarget();
const [feedbackSent, setFeedbackSent] = React.useState(false);

React.useEffect(() => {
if (feedbackSent) {
const timeout = setTimeout(() => setFeedbackSent(false), 5000);
return () => clearTimeout(timeout);
}
}, [feedbackSent]);

return (
<div>
<Field label="Compose message" className={styles.field}>
<Textarea />
</Field>
<br />
<Button {...restoreFocusTargetAttribute}>Send message</Button>
{!feedbackSent ? (
<div {...restoreFocusSourceAttribute} className={styles.feedback}>
How was your experience completing this task?
<Button appearance="subtle" onClick={() => setFeedbackSent(true)} icon={<ThumbLikeRegular />} />
<Button appearance="subtle" onClick={() => setFeedbackSent(true)} icon={<ThumbDislikeRegular />} />
</div>
) : (
<div>Thanks for submitting feedback!</div>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import * as React from 'react';
import { ThumbLikeRegular, ThumbDislikeRegular } from '@fluentui/react-icons';
import {
useRestoreFocusSource,
useRestoreFocusTarget,
Button,
makeStyles,
Textarea,
Field,
} from '@fluentui/react-components';

const useStyles = makeStyles({
feedback: {
display: 'flex',
alignItems: 'center',
},

field: {
width: '300px',
},
});

export const FocusRestoreHistory = () => {
const styles = useStyles();
const restoreFocusSourceAttribute = useRestoreFocusSource();
const restoreFocusTargetAttribute = useRestoreFocusTarget();
const [experienceFeedbackSent, setExperienceFeedbackSent] = React.useState(false);
const [deliveryFeedbackSent, setDeliveryFeedbackSent] = React.useState(false);

React.useEffect(() => {
// reset example
if (experienceFeedbackSent) {
const timeout = setTimeout(() => setExperienceFeedbackSent(false), 5000);
return () => clearTimeout(timeout);
}
}, [experienceFeedbackSent]);

React.useEffect(() => {
// reset example
if (deliveryFeedbackSent) {
const timeout = setTimeout(() => setDeliveryFeedbackSent(false), 5000);
return () => clearTimeout(timeout);
}
}, [deliveryFeedbackSent]);

return (
<div>
<Field label="Compose message" className={styles.field}>
<Textarea />
</Field>
<br />
<Button {...restoreFocusTargetAttribute}>Send message</Button>
{!experienceFeedbackSent ? (
<>
<div {...restoreFocusSourceAttribute} className={styles.feedback}>
How was your experience completing this task?
<Button
{...restoreFocusTargetAttribute}
appearance="subtle"
onClick={() => setExperienceFeedbackSent(true)}
icon={<ThumbLikeRegular />}
/>
<Button
appearance="subtle"
onClick={() => setExperienceFeedbackSent(true)}
icon={<ThumbDislikeRegular />}
/>
</div>
</>
) : (
<div>Thanks for submitting feedback!</div>
)}
{!deliveryFeedbackSent ? (
<>
<div {...restoreFocusSourceAttribute} className={styles.feedback}>
Was your message delivered successfully?
<Button appearance="subtle" onClick={() => setDeliveryFeedbackSent(true)} icon={<ThumbLikeRegular />} />
<Button appearance="subtle" onClick={() => setDeliveryFeedbackSent(true)} icon={<ThumbDislikeRegular />} />
</div>
</>
) : (
<div>Thanks for submitting feedback!</div>
)}
</div>
);
};

FocusRestoreHistory.parameters = {
docs: {
description: {
story: [
'Target elements are stored in a limited history. In this example try to submit the feedback in reverse order.',
'The first feedback button is a restore target, so once the second feedback is submitted focus is restored',
'to the first feedback button. Likewise once the first feedback is submitted, focus will be restored to the',
'send button.',
].join('\n'),
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as React from 'react';
import { ThumbLikeRegular, ThumbDislikeRegular } from '@fluentui/react-icons';
import {
useRestoreFocusSource,
useRestoreFocusTarget,
Button,
makeStyles,
Textarea,
Field,
} from '@fluentui/react-components';

const useStyles = makeStyles({
feedback: {
display: 'flex',
alignItems: 'center',
},

field: {
width: '300px',
},
});

export const UserRestoreFocus = () => {
const styles = useStyles();
const restoreFocusSourceAttribute = useRestoreFocusSource();
const restoreFocusTargetAttribute = useRestoreFocusTarget();
const sendButtonRef = React.useRef<HTMLButtonElement | null>(null);
const [experienceFeedbackSent, setExperienceFeedbackSent] = React.useState(false);
const [deliveryFeedbackSent, setDeliveryFeedbackSent] = React.useState(false);

React.useEffect(() => {
// reset example
if (experienceFeedbackSent) {
const timeout = setTimeout(() => setExperienceFeedbackSent(false), 5000);
return () => clearTimeout(timeout);
}
}, [experienceFeedbackSent]);

React.useEffect(() => {
// reset example
if (deliveryFeedbackSent) {
sendButtonRef.current?.focus();
const timeout = setTimeout(() => setDeliveryFeedbackSent(false), 5000);
return () => clearTimeout(timeout);
}
}, [deliveryFeedbackSent]);

return (
<div>
<Field label="Compose message" className={styles.field}>
<Textarea />
</Field>
<br />
<Button ref={sendButtonRef} {...restoreFocusTargetAttribute}>
Send message
</Button>
{!experienceFeedbackSent ? (
<>
<div {...restoreFocusSourceAttribute} className={styles.feedback}>
How was your experience completing this task?
<Button
{...restoreFocusTargetAttribute}
appearance="subtle"
onClick={() => setExperienceFeedbackSent(true)}
icon={<ThumbLikeRegular />}
/>
<Button
appearance="subtle"
onClick={() => setExperienceFeedbackSent(true)}
icon={<ThumbDislikeRegular />}
/>
</div>
</>
) : (
<div>Thanks for submitting feedback!</div>
)}
{!deliveryFeedbackSent ? (
<>
<div {...restoreFocusSourceAttribute} className={styles.feedback}>
Was your message delivered successfully?
<Button appearance="subtle" onClick={() => setDeliveryFeedbackSent(true)} icon={<ThumbLikeRegular />} />
<Button appearance="subtle" onClick={() => setDeliveryFeedbackSent(true)} icon={<ThumbDislikeRegular />} />
</div>
</>
) : (
<div>Thanks for submitting feedback!</div>
)}
</div>
);
};

UserRestoreFocus.parameters = {
docs: {
description: {
story: [
'If the user manually moves focus to a desired element, then the utility **will not move focus**.',
'The focus will only be restored if it is lost to the `document body`.',
'',
'This example is similar to the previous. However, submitting the second feedback will manually move',
"focus to the 'Send message' button. This bypasses the restore focus history, which should restore",
'focus to the first feedback button.',
].join('\n'),
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useRestoreFocusSource } from '@fluentui/react-components';
import descriptionMd from './useRestoreFocusSourceDescription.md';

export { Default } from './Default.stories.stories';
export { FocusRestoreHistory } from './FocusRestoreHistory.stories';
export { UserRestoreFocus } from './UserRestoreFocus.stories.stories';

export default {
title: 'Utilities/Focus Management/useRestoreFocusSource',
component: useRestoreFocusSource,
parameters: {
docs: {
description: {
component: [descriptionMd].join('\n'),
},
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
The hooks `useRestoreFocusSource` and `useRestoreFocusTarget` are intended to be used together, but without tight
coupling.

When the attribute returned by `useRestoreFocusSource` is applied to an element, it will be ready to restore focus
to the last 'bookmarked' element that was set using `useRestoreFocusTarget`. The restore focus target
**needs to be focused** before focus is lost from a source. This is to prevent focus randomly jumping across
an application but being restored to the an element at the closest point in time.

The examples below simulate a feedback experience. One a user submits feedback, the control will be removed from
the page and the focus will need to revert from the body (since the focused element was removed).
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('DialogTrigger', () => {
expect(ref.mock.calls[0]).toMatchInlineSnapshot(`
Array [
<button
data-tabster="{\\"deloser\\":{}}"
data-tabster="{\\"restorer\\":{\\"type\\":1}}"
>
Trigger
</button>,
Expand Down Expand Up @@ -85,7 +85,7 @@ describe('DialogTrigger', () => {
expect(cb.mock.calls[0]).toMatchInlineSnapshot(`
Array [
<button
data-tabster="{\\"deloser\\":{}}"
data-tabster="{\\"restorer\\":{\\"type\\":1}}"
>
Trigger
</button>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

exports[`DialogTrigger renders a default state 1`] = `
<button
data-tabster="{\\"deloser\\":{}}"
data-tabster="{\\"restorer\\":{\\"type\\":1}}"
onClick={[Function]}
>
Dialog trigger
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { applyTriggerPropsToChildren, getTriggerChild, useEventCallback } from '
import type { DialogTriggerProps, DialogTriggerState } from './DialogTrigger.types';
import { useDialogContext_unstable, useDialogSurfaceContext_unstable } from '../../contexts';
import { useARIAButtonProps } from '@fluentui/react-aria';
import { useModalAttributes } from '@fluentui/react-tabster';

/**
* Create the state required to render DialogTrigger.
Expand All @@ -18,7 +19,7 @@ export const useDialogTrigger_unstable = (props: DialogTriggerProps): DialogTrig
const child = getTriggerChild(children);

const requestOpenChange = useDialogContext_unstable(ctx => ctx.requestOpenChange);
const triggerAttributes = useDialogContext_unstable(ctx => ctx.triggerAttributes);
const { triggerAttributes } = useModalAttributes();

const handleClick = useEventCallback(
(event: React.MouseEvent<HTMLButtonElement & HTMLAnchorElement & HTMLDivElement>) => {
Expand Down
Loading

0 comments on commit c0c62f9

Please sign in to comment.