diff --git a/apps/vr-tests-react-components/src/stories/Link.stories.tsx b/apps/vr-tests-react-components/src/stories/Link.stories.tsx index 22d9f04ed3ed9..fb7016111f52b 100644 --- a/apps/vr-tests-react-components/src/stories/Link.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Link.stories.tsx @@ -14,12 +14,10 @@ storiesOf('Link Converged - Rendered as anchor', module) .hover('.fui-Link') .snapshot('hover', { cropTo: '.testWrapper' }) // This needs to be added so that the focus outline is shown correctly - .executeScript( - "document.getElementsByClassName('fui-FluentProvider')[0].setAttribute('data-keyboard-nav', true)", - ) + .executeScript("document.getElementsByClassName('fui-Link')[0].classList.add('fui-focus-visible')") .focus('.fui-Link') .snapshot('focused', { cropTo: '.testWrapper' }) - .executeScript("document.getElementsByClassName('fui-FluentProvider')[0].removeAttribute('data-keyboard-nav')") + .executeScript("document.getElementsByClassName('fui-Link')[0].classList.remove('fui-focus-visible')") .mouseDown('.fui-Link') .snapshot('pressed', { cropTo: '.testWrapper' }) .mouseUp('.fui-Link') @@ -96,12 +94,10 @@ storiesOf('Link Converged - Rendered as button', module) .hover('.fui-Link') .snapshot('hover', { cropTo: '.testWrapper' }) // This needs to be added so that the focus outline is shown correctly - .executeScript( - "document.getElementsByClassName('fui-FluentProvider')[0].setAttribute('data-keyboard-nav', true)", - ) + .executeScript("document.getElementsByClassName('fui-Link')[0].classList.add('fui-focus-visible')") .focus('.fui-Link') .snapshot('focused', { cropTo: '.testWrapper' }) - .executeScript("document.getElementsByClassName('fui-FluentProvider')[0].removeAttribute('data-keyboard-nav')") + .executeScript("document.getElementsByClassName('fui-Link')[0].classList.remove('fui-focus-visible')") .mouseDown('.fui-Link') .snapshot('pressed', { cropTo: '.testWrapper' }) .mouseUp('.fui-Link') diff --git a/change/@fluentui-react-checkbox-25fb0c36-d07f-4a39-bd82-831bb2b756b0.json b/change/@fluentui-react-checkbox-25fb0c36-d07f-4a39-bd82-831bb2b756b0.json new file mode 100644 index 0000000000000..4e9548e3e4dc9 --- /dev/null +++ b/change/@fluentui-react-checkbox-25fb0c36-d07f-4a39-bd82-831bb2b756b0.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "refactor: use `useFocusWithin` hook for :focus-within styles", + "packageName": "@fluentui/react-checkbox", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-portal-2940da05-fb94-4cac-8763-6e0c4a14ee8f.json b/change/@fluentui-react-portal-2940da05-fb94-4cac-8763-6e0c4a14ee8f.json new file mode 100644 index 0000000000000..d5cc94b170402 --- /dev/null +++ b/change/@fluentui-react-portal-2940da05-fb94-4cac-8763-6e0c4a14ee8f.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: use `useFocusVisible` hook for :focus-visible styles", + "packageName": "@fluentui/react-portal", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-provider-9ddc6123-46e0-4094-9670-659f00f241b8.json b/change/@fluentui-react-provider-9ddc6123-46e0-4094-9670-659f00f241b8.json new file mode 100644 index 0000000000000..9b807655923dd --- /dev/null +++ b/change/@fluentui-react-provider-9ddc6123-46e0-4094-9670-659f00f241b8.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: use `useFocusVisible` hook for :focus-visible styles", + "packageName": "@fluentui/react-provider", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-radio-36321438-6ccb-4827-85da-098c7e6c76cf.json b/change/@fluentui-react-radio-36321438-6ccb-4827-85da-098c7e6c76cf.json new file mode 100644 index 0000000000000..d0bba8a61be1b --- /dev/null +++ b/change/@fluentui-react-radio-36321438-6ccb-4827-85da-098c7e6c76cf.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "refactor: use `useFocusWithin` hook for :focus-within styles", + "packageName": "@fluentui/react-radio", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-slider-e562a2af-50fe-4b42-9eec-003613926997.json b/change/@fluentui-react-slider-e562a2af-50fe-4b42-9eec-003613926997.json new file mode 100644 index 0000000000000..2fd685201fff5 --- /dev/null +++ b/change/@fluentui-react-slider-e562a2af-50fe-4b42-9eec-003613926997.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "refactor: use `useFocusWithin` hook for :focus-within styles", + "packageName": "@fluentui/react-slider", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-switch-504a98f0-bdbd-4377-959c-1dcb2275040a.json b/change/@fluentui-react-switch-504a98f0-bdbd-4377-959c-1dcb2275040a.json new file mode 100644 index 0000000000000..1fb23928e5938 --- /dev/null +++ b/change/@fluentui-react-switch-504a98f0-bdbd-4377-959c-1dcb2275040a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "refactor: use `useFocusWithin` hook for :focus-within styles", + "packageName": "@fluentui/react-switch", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-tabster-897f1851-6bb3-4082-8a54-ace4ca191faf.json b/change/@fluentui-react-tabster-897f1851-6bb3-4082-8a54-ace4ca191faf.json new file mode 100644 index 0000000000000..e50bcd666bf5f --- /dev/null +++ b/change/@fluentui-react-tabster-897f1851-6bb3-4082-8a54-ace4ca191faf.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: create `:focus-visible` and `:focus-within` polyfills", + "packageName": "@fluentui/react-tabster", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-checkbox/src/components/Checkbox/useCheckbox.tsx b/packages/react-components/react-checkbox/src/components/Checkbox/useCheckbox.tsx index 45d8b634e2995..70fed20abde2c 100644 --- a/packages/react-components/react-checkbox/src/components/Checkbox/useCheckbox.tsx +++ b/packages/react-components/react-checkbox/src/components/Checkbox/useCheckbox.tsx @@ -17,6 +17,7 @@ import { CircleFilled, } from '@fluentui/react-icons'; import { Label } from '@fluentui/react-label'; +import { useFocusWithin } from '@fluentui/react-tabster'; /** * Create the state required to render Checkbox. @@ -69,7 +70,10 @@ export const useCheckbox_unstable = (props: CheckboxProps, ref: React.Ref(), + ...nativeProps.root, + }, }), input: resolveShorthand(props.input, { required: true, diff --git a/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts b/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts index 5b9059b977635..89558727cc0b9 100644 --- a/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts +++ b/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts @@ -4,8 +4,8 @@ import { useThemeClassName_unstable as useThemeClassName, useFluent_unstable as useFluent, } from '@fluentui/react-shared-contexts'; -import { useKeyboardNavAttribute } from '@fluentui/react-tabster'; import { makeStyles, mergeClasses } from '@griffel/react'; +import { useFocusVisible } from '@fluentui/react-tabster'; export type UsePortalMountNodeOptions = { /** @@ -26,6 +26,7 @@ const useStyles = makeStyles({ */ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElement | null => { const { targetDocument, dir } = useFluent(); + const focusVisibleRef = useFocusVisible() as React.MutableRefObject; const classes = useStyles(); const themeClassName = useThemeClassName(); @@ -49,15 +50,14 @@ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElem element.classList.add(...classesToApply); element.setAttribute('dir', dir); + focusVisibleRef.current = element; return () => { element.classList.remove(...classesToApply); element.removeAttribute('dir'); }; } - }, [element, className, dir]); - - (useKeyboardNavAttribute() as React.MutableRefObject).current = element!; + }, [className, dir, element, focusVisibleRef]); React.useEffect(() => { return () => { diff --git a/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.ts b/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.ts index e4da2989ed543..489f35f1f053e 100644 --- a/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.ts +++ b/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.ts @@ -1,4 +1,4 @@ -import { useKeyboardNavAttribute } from '@fluentui/react-tabster'; +import { useFocusVisible } from '@fluentui/react-tabster'; import { ThemeContext_unstable as ThemeContext, useFluent_unstable as useFluent, @@ -58,7 +58,7 @@ export const useFluentProvider_unstable = ( root: getNativeElementProps('div', { ...props, dir, - ref: useMergedRefs(ref, useKeyboardNavAttribute()), + ref: useMergedRefs(ref, useFocusVisible()), }), }; }; diff --git a/packages/react-components/react-radio/src/components/Radio/useRadio.tsx b/packages/react-components/react-radio/src/components/Radio/useRadio.tsx index 1be6b3e646e2d..4915947b319d1 100644 --- a/packages/react-components/react-radio/src/components/Radio/useRadio.tsx +++ b/packages/react-components/react-radio/src/components/Radio/useRadio.tsx @@ -4,6 +4,7 @@ import { Label } from '@fluentui/react-label'; import { getPartitionedNativeProps, resolveShorthand, useId, useMergedEventCallbacks } from '@fluentui/react-utilities'; import { RadioGroupContext } from '../../contexts/RadioGroupContext'; import { useContextSelector } from '@fluentui/react-context-selector'; +import { useFocusWithin } from '@fluentui/react-tabster'; import type { RadioProps, RadioState } from './Radio.types'; /** @@ -41,7 +42,10 @@ export const useRadio_unstable = (props: RadioProps, ref: React.Ref(), + ...nativeProps.root, + }, }); const input = resolveShorthand(props.input, { diff --git a/packages/react-components/react-slider/src/components/Slider/useSlider.ts b/packages/react-components/react-slider/src/components/Slider/useSlider.ts index 7873d3620d770..8797b52625c05 100644 --- a/packages/react-components/react-slider/src/components/Slider/useSlider.ts +++ b/packages/react-components/react-slider/src/components/Slider/useSlider.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import { getPartitionedNativeProps, resolveShorthand, useId } from '@fluentui/react-utilities'; import { useSliderState_unstable } from './useSliderState'; import { SliderProps, SliderState } from './Slider.types'; +import { useFocusWithin } from '@fluentui/react-tabster'; export const useSlider_unstable = (props: SliderProps, ref: React.Ref): SliderState => { const nativeProps = getPartitionedNativeProps({ @@ -34,6 +35,7 @@ export const useSlider_unstable = (props: SliderProps, ref: React.Ref(), ...nativeProps.root, }, }), diff --git a/packages/react-components/react-switch/src/components/Switch/useSwitch.tsx b/packages/react-components/react-switch/src/components/Switch/useSwitch.tsx index 4875b3ffcdc50..0583367c72e64 100644 --- a/packages/react-components/react-switch/src/components/Switch/useSwitch.tsx +++ b/packages/react-components/react-switch/src/components/Switch/useSwitch.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { CircleFilled } from '@fluentui/react-icons'; import { Label } from '@fluentui/react-label'; +import { useFocusWithin } from '@fluentui/react-tabster'; import { getPartitionedNativeProps, resolveShorthand, useId, useMergedEventCallbacks } from '@fluentui/react-utilities'; import type { SwitchProps, SwitchState } from './Switch.types'; @@ -25,7 +26,7 @@ export const useSwitch_unstable = (props: SwitchProps, ref: React.Ref(), ...nativeProps.root }, required: true, }); diff --git a/packages/react-components/react-tabster/etc/react-tabster.api.md b/packages/react-components/react-tabster/etc/react-tabster.api.md index ee5c5612fba7f..ac4ae061269cc 100644 --- a/packages/react-components/react-tabster/etc/react-tabster.api.md +++ b/packages/react-components/react-tabster/etc/react-tabster.api.md @@ -5,6 +5,7 @@ ```ts import type { GriffelStyle } from '@griffel/react'; +import * as React_2 from 'react'; import type { RefObject } from 'react'; import { Types } from 'tabster'; @@ -66,6 +67,12 @@ export const useFocusFinders: () => { findPrevFocusable: (currentElement: HTMLElement, options?: Pick) => HTMLElement | null | undefined; }; +// @public (undocumented) +export function useFocusVisible(): React_2.RefObject; + +// @public +export function useFocusWithin(): React_2.RefObject; + // @public export function useKeyboardNavAttribute(): RefObject; diff --git a/packages/react-components/react-tabster/src/focus/constants.ts b/packages/react-components/react-tabster/src/focus/constants.ts index 2d9961023dda4..ff689f9b1283c 100644 --- a/packages/react-components/react-tabster/src/focus/constants.ts +++ b/packages/react-components/react-tabster/src/focus/constants.ts @@ -1,5 +1,7 @@ export const KEYBOARD_NAV_ATTRIBUTE = 'data-keyboard-nav' as const; export const KEYBOARD_NAV_SELECTOR = `:global([${KEYBOARD_NAV_ATTRIBUTE}])` as const; +export const FOCUS_VISIBLE_CLASS = 'fui-focus-visible'; +export const FOCUS_WITHIN_CLASS = 'fui-focus-within'; export const defaultOptions = { style: {}, selector: 'focus', diff --git a/packages/react-components/react-tabster/src/focus/createCustomFocusIndicatorStyle.ts b/packages/react-components/react-tabster/src/focus/createCustomFocusIndicatorStyle.ts index d5a153c0418db..31c1e4564f2bd 100644 --- a/packages/react-components/react-tabster/src/focus/createCustomFocusIndicatorStyle.ts +++ b/packages/react-components/react-tabster/src/focus/createCustomFocusIndicatorStyle.ts @@ -1,4 +1,4 @@ -import { KEYBOARD_NAV_SELECTOR, defaultOptions } from './constants'; +import { FOCUS_VISIBLE_CLASS, defaultOptions, FOCUS_WITHIN_CLASS } from './constants'; import type { GriffelStyle } from '@griffel/react'; export interface CreateCustomFocusIndicatorStyleOptions { @@ -19,5 +19,19 @@ export const createCustomFocusIndicatorStyle = ( ':focus': { outlineStyle: 'none', }, - [`${KEYBOARD_NAV_SELECTOR} :${selector}`]: style, + ':focus-visible': { + outlineStyle: 'none', + }, + // Remove the `.fui-FluentProvider` global selector once Griffel supports chained global styles + // https://github.com/microsoft/griffel/issues/178 + ...(selector === 'focus' && { + [`:global(.fui-FluentProvider)`]: { + [`& .${FOCUS_VISIBLE_CLASS}`]: style, + }, + }), + ...(selector === 'focus-within' && { + [`:global(.fui-FluentProvider)`]: { + [`& .${FOCUS_WITHIN_CLASS}:${selector}`]: style, + }, + }), }); diff --git a/packages/react-components/react-tabster/src/focus/focusVisiblePolyfill.ts b/packages/react-components/react-tabster/src/focus/focusVisiblePolyfill.ts new file mode 100644 index 0000000000000..28a62fa8f32f2 --- /dev/null +++ b/packages/react-components/react-tabster/src/focus/focusVisiblePolyfill.ts @@ -0,0 +1,105 @@ +import { KEYBORG_FOCUSIN, KeyborgFocusInEvent, createKeyborg, disposeKeyborg } from 'keyborg'; +import { FOCUS_VISIBLE_CLASS } from './constants'; + +/** + * Because `addEventListener` type override falls back to 2nd definition (evt name is unknown string literal) + * evt is being typed as a base class of MouseEvent -> `Event`. + * This type is used to override `listener` calls to make TS happy + */ +type ListenerOverride = (evt: Event) => void; + +type FocusVisibleState = { + /** + * Current element with focus visible in state + */ + current: HTMLElement | undefined; +}; + +type HTMLElementWithFocusVisibleScope = { + focusVisible: boolean | undefined; +} & HTMLElement; + +export function applyFocusVisiblePolyfill(scope: HTMLElement, win: Window): () => void { + if (alreadyInScope(scope)) { + // Focus visible polyfill already applied at this scope + return () => undefined; + } + + const state: FocusVisibleState = { + current: undefined, + }; + + const keyborg = createKeyborg(win); + + // When navigation mode changes remove the focus-visible selector + keyborg.subscribe(isNavigatingWithKeyboard => { + if (!isNavigatingWithKeyboard && state.current) { + removeFocusVisibleClass(state.current); + state.current = undefined; + } + }); + + // Keyborg's focusin event is delegated so it's only registered once on the window + // and contains metadata about the focus event + const keyborgListener = (e: KeyborgFocusInEvent) => { + if (state.current) { + removeFocusVisibleClass(state.current); + state.current = undefined; + } + + if (keyborg.isNavigatingWithKeyboard() && isHTMLElement(e.target) && e.target) { + // Griffel can't create chained global styles so use the parent element for now + state.current = e.target; + applyFocusVisibleClass(state.current); + } + }; + + // Make sure that when focus leaves the scope, the focus visible class is removed + const blurListener = (e: FocusEvent) => { + if (!e.relatedTarget || (isHTMLElement(e.relatedTarget) && !scope.contains(e.relatedTarget))) { + if (state.current) { + removeFocusVisibleClass(state.current); + state.current = undefined; + } + } + }; + + scope.addEventListener(KEYBORG_FOCUSIN, keyborgListener as ListenerOverride); + scope.addEventListener('focusout', blurListener); + (scope as HTMLElementWithFocusVisibleScope).focusVisible = true; + + // Return disposer + return () => { + scope.removeEventListener(KEYBORG_FOCUSIN, keyborgListener as ListenerOverride); + scope.removeEventListener('focusout', blurListener); + delete (scope as HTMLElementWithFocusVisibleScope).focusVisible; + disposeKeyborg(keyborg); + }; +} + +function applyFocusVisibleClass(el: HTMLElement) { + el.classList.add(FOCUS_VISIBLE_CLASS); +} + +function removeFocusVisibleClass(el: HTMLElement) { + el.classList.remove(FOCUS_VISIBLE_CLASS); +} + +function isHTMLElement(target: EventTarget | null): target is HTMLElement { + if (!target) { + return false; + } + return Boolean(target && typeof target === 'object' && 'classList' in target && 'contains' in target); +} + +function alreadyInScope(el: HTMLElement | null | undefined): boolean { + if (!el) { + return false; + } + + if ((el as HTMLElementWithFocusVisibleScope).focusVisible) { + return true; + } + + return alreadyInScope(el?.parentElement); +} diff --git a/packages/react-components/react-tabster/src/focus/focusWithinPolyfill.ts b/packages/react-components/react-tabster/src/focus/focusWithinPolyfill.ts new file mode 100644 index 0000000000000..86ef9abc2e85c --- /dev/null +++ b/packages/react-components/react-tabster/src/focus/focusWithinPolyfill.ts @@ -0,0 +1,66 @@ +import { KEYBORG_FOCUSIN, KeyborgFocusInEvent, createKeyborg, disposeKeyborg } from 'keyborg'; +import { FOCUS_WITHIN_CLASS } from './constants'; + +/** + * Because `addEventListener` type override falls back to 2nd definition (evt name is unknown string literal) + * evt is being typed as a base class of MouseEvent -> `Event`. + * This type is used to override `listener` calls to make TS happy + */ +type ListenerOverride = (evt: Event) => void; + +/** + * A ponyfill that allows `:focus-within` to support visibility based on keyboard/mouse navigation + * like `:focus-visible` https://github.com/WICG/focus-visible/issues/151 + * @returns ref to the element that uses `:focus-within` styles + */ +export function applyFocusWithinPolyfill(element: HTMLElement, win: Window): () => void { + const keyborg = createKeyborg(win); + + // When navigation mode changes to mouse, remove the focus-within selector + keyborg.subscribe(isNavigatingWithKeyboard => { + if (!isNavigatingWithKeyboard) { + removeFocusWithinClass(element); + } + }); + + // Keyborg's focusin event is delegated so it's only registered once on the window + // and contains metadata about the focus event + const keyborgListener = (e: KeyborgFocusInEvent) => { + if (keyborg.isNavigatingWithKeyboard() && isHTMLElement(e.target)) { + // Griffel can't create chained global styles so use the parent element for now + applyFocusWithinClass(element); + } + }; + + // Make sure that when focus leaves the scope, the focus within class is removed + const blurListener = (e: FocusEvent) => { + if (!e.relatedTarget || (isHTMLElement(e.relatedTarget) && !element.contains(e.relatedTarget))) { + removeFocusWithinClass(element); + } + }; + + element.addEventListener(KEYBORG_FOCUSIN, keyborgListener as ListenerOverride); + element.addEventListener('focusout', blurListener); + + // Return disposer + return () => { + element.removeEventListener(KEYBORG_FOCUSIN, keyborgListener as ListenerOverride); + element.removeEventListener('focusout', blurListener); + disposeKeyborg(keyborg); + }; +} + +function applyFocusWithinClass(el: HTMLElement) { + el.classList.add(FOCUS_WITHIN_CLASS); +} + +function removeFocusWithinClass(el: HTMLElement) { + el.classList.remove(FOCUS_WITHIN_CLASS); +} + +function isHTMLElement(target: EventTarget | null): target is HTMLElement { + if (!target) { + return false; + } + return Boolean(target && typeof target === 'object' && 'classList' in target && 'contains' in target); +} diff --git a/packages/react-components/react-tabster/src/hooks/index.ts b/packages/react-components/react-tabster/src/hooks/index.ts index 0aa6a9eb43852..b1ba1fc9e5d4f 100644 --- a/packages/react-components/react-tabster/src/hooks/index.ts +++ b/packages/react-components/react-tabster/src/hooks/index.ts @@ -1,6 +1,8 @@ export * from './useArrowNavigationGroup'; export * from './useFocusableGroup'; export * from './useFocusFinders'; +export * from './useFocusVisible'; +export * from './useFocusWithin'; export * from './useKeyboardNavAttribute'; export * from './useModalAttributes'; export * from './useTabsterAttributes'; diff --git a/packages/react-components/react-tabster/src/hooks/useFocusVisible.ts b/packages/react-components/react-tabster/src/hooks/useFocusVisible.ts new file mode 100644 index 0000000000000..f32d148eef862 --- /dev/null +++ b/packages/react-components/react-tabster/src/hooks/useFocusVisible.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { applyFocusVisiblePolyfill } from '../focus/focusVisiblePolyfill'; + +export function useFocusVisible() { + const { targetDocument } = useFluent(); + const scopeRef = React.useRef(null); + + React.useEffect(() => { + if (targetDocument?.defaultView && scopeRef.current) { + return applyFocusVisiblePolyfill(scopeRef.current, targetDocument.defaultView); + } + }, [scopeRef, targetDocument]); + + return scopeRef; +} diff --git a/packages/react-components/react-tabster/src/hooks/useFocusWithin.ts b/packages/react-components/react-tabster/src/hooks/useFocusWithin.ts new file mode 100644 index 0000000000000..8e3a20077ffeb --- /dev/null +++ b/packages/react-components/react-tabster/src/hooks/useFocusWithin.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { applyFocusWithinPolyfill } from '../focus/focusWithinPolyfill'; + +/** + * A ponyfill that allows `:focus-within` to support visibility based on keyboard/mouse navigation + * like `:focus-visible` https://github.com/WICG/focus-visible/issues/151 + * @returns ref to the element that uses `:focus-within` styles + */ +export function useFocusWithin() { + const { targetDocument } = useFluent(); + const elementRef = React.useRef(null); + + React.useEffect(() => { + if (targetDocument?.defaultView && elementRef.current) { + return applyFocusWithinPolyfill(elementRef.current, targetDocument.defaultView); + } + }, [elementRef, targetDocument]); + + return elementRef; +} diff --git a/packages/react-components/react-tabster/src/index.ts b/packages/react-components/react-tabster/src/index.ts index 3c31f32a11c91..0d440239bd0b9 100644 --- a/packages/react-components/react-tabster/src/index.ts +++ b/packages/react-components/react-tabster/src/index.ts @@ -2,6 +2,8 @@ export { useArrowNavigationGroup, useFocusableGroup, useFocusFinders, + useFocusVisible, + useFocusWithin, useKeyboardNavAttribute, useModalAttributes, useTabsterAttributes,