Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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