-
Notifications
You must be signed in to change notification settings - Fork 39
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
714 additions
and
0 deletions.
There are no files selected for viewing
139 changes: 139 additions & 0 deletions
139
@navikt/core/react/src/form/virtualfocus/RovingFocus.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
import React, { | ||
forwardRef, | ||
useCallback, | ||
useEffect, | ||
useRef, | ||
useState, | ||
} from "react"; | ||
import { Slot } from "../../util/Slot"; | ||
import { composeEventHandlers } from "../../util/composeEventHandlers"; | ||
import { useCallbackRef, useMergeRefs } from "../../util/hooks"; | ||
import { DescendantsManager } from "../../util/hooks/descendants/descendant"; | ||
|
||
export interface RovingFocusProps extends React.HTMLAttributes<HTMLDivElement> { | ||
asChild?: boolean; | ||
descendants: DescendantsManager<HTMLDivElement, object>; | ||
onEntryFocus?: (event: Event) => void; | ||
} | ||
|
||
const ENTRY_FOCUS = "rovingFocusGroup.onEntryFocus"; | ||
const EVENT_OPTIONS = { bubbles: false, cancelable: true }; | ||
|
||
export const RovingFocus = forwardRef<HTMLDivElement, RovingFocusProps>( | ||
( | ||
{ | ||
children, | ||
asChild, | ||
descendants, | ||
onKeyDown, | ||
onEntryFocus, | ||
onMouseDown, | ||
onBlur, | ||
onFocus, | ||
...rest | ||
}: RovingFocusProps, | ||
ref, | ||
) => { | ||
const _ref = React.useRef<HTMLDivElement>(null); | ||
const composedRefs = useMergeRefs(ref, _ref); | ||
|
||
const handleEntryFocus = useCallbackRef(onEntryFocus); | ||
const [isTabbingBackOut, setIsTabbingBackOut] = useState(false); | ||
const isClickFocusRef = useRef(false); | ||
|
||
useEffect(() => { | ||
const node = _ref.current; | ||
if (node) { | ||
node.addEventListener(ENTRY_FOCUS, handleEntryFocus); | ||
return () => node.removeEventListener(ENTRY_FOCUS, handleEntryFocus); | ||
} | ||
}, [handleEntryFocus]); | ||
|
||
/* TODO: implement ownerdocument here */ | ||
const handleKeyDown = useCallback( | ||
(event: React.KeyboardEvent) => { | ||
const loop = false; | ||
/** | ||
* Tabs.Tab is registered with its prop 'value'. | ||
* We can then use it to find the current focuses descendant | ||
*/ | ||
const idx = descendants | ||
.values() | ||
.findIndex((x) => x.node.isSameNode(document.activeElement)); | ||
|
||
const nextTab = () => { | ||
const next = descendants.nextEnabled(idx, loop); | ||
next && next.node?.focus(); | ||
}; | ||
const prevTab = () => { | ||
const prev = descendants.prevEnabled(idx, loop); | ||
prev && prev.node?.focus(); | ||
}; | ||
const firstTab = () => { | ||
const first = descendants.firstEnabled(); | ||
first && first.node?.focus(); | ||
}; | ||
const lastTab = () => { | ||
const last = descendants.lastEnabled(); | ||
last && last.node?.focus(); | ||
}; | ||
|
||
const keyMap: Record<string, React.KeyboardEventHandler> = { | ||
ArrowUp: prevTab, | ||
ArrowDown: nextTab, | ||
Home: firstTab, | ||
End: lastTab, | ||
}; | ||
|
||
const action = keyMap[event.key]; | ||
|
||
if (action) { | ||
event.preventDefault(); | ||
action(event); | ||
} | ||
}, | ||
[descendants], | ||
); | ||
|
||
const Comp = asChild ? Slot : "div"; | ||
|
||
return ( | ||
<Comp | ||
ref={composedRefs} | ||
{...rest} | ||
tabIndex={isTabbingBackOut || descendants.enabledCount() === 0 ? -1 : 0} | ||
style={{ outline: "none", ...rest.style }} | ||
onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)} | ||
onMouseDown={composeEventHandlers(onMouseDown, () => { | ||
isClickFocusRef.current = true; | ||
})} | ||
onFocus={composeEventHandlers(onFocus, (event) => { | ||
// We normally wouldn't need this check, because we already check | ||
// that the focus is on the current target and not bubbling to it. | ||
// We do this because Safari doesn't focus buttons when clicked, and | ||
// instead, the wrapper will get focused and not through a bubbling event. | ||
const isKeyboardFocus = !isClickFocusRef.current; | ||
|
||
if ( | ||
event.target === event.currentTarget && | ||
isKeyboardFocus && | ||
!isTabbingBackOut | ||
) { | ||
const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); | ||
event.currentTarget.dispatchEvent(entryFocusEvent); | ||
|
||
if (!entryFocusEvent.defaultPrevented) { | ||
console.log("focusing first"); | ||
descendants.firstEnabled()?.node.focus({ preventScroll: true }); | ||
} | ||
} | ||
|
||
isClickFocusRef.current = false; | ||
})} | ||
onBlur={composeEventHandlers(onBlur, () => setIsTabbingBackOut(false))} | ||
> | ||
{children} | ||
</Comp> | ||
); | ||
}, | ||
); |
17 changes: 17 additions & 0 deletions
17
@navikt/core/react/src/form/virtualfocus/SlottedDivElement.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import React, { forwardRef } from "react"; | ||
import { Slot } from "../../util/Slot"; | ||
|
||
interface SlottedDivProps extends React.HTMLAttributes<HTMLDivElement> { | ||
asChild?: boolean; | ||
} | ||
|
||
const SlottedDivElement = forwardRef<HTMLDivElement, SlottedDivProps>( | ||
({ asChild, ...rest }, forwardedRef) => { | ||
const Comp = asChild ? Slot : "div"; | ||
return <Comp {...rest} ref={forwardedRef} />; | ||
}, | ||
); | ||
|
||
type SlottedDivElementRef = React.ElementRef<typeof SlottedDivElement>; | ||
|
||
export { SlottedDivElement, type SlottedDivElementRef, type SlottedDivProps }; |
235 changes: 235 additions & 0 deletions
235
@navikt/core/react/src/form/virtualfocus/VirtualFocus.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,235 @@ | ||
import React, { | ||
Dispatch, | ||
SetStateAction, | ||
forwardRef, | ||
useId, | ||
useState, | ||
} from "react"; | ||
import { Slot } from "../../util/Slot"; | ||
import { createContext } from "../../util/create-context"; | ||
import { useMergeRefs } from "../../util/hooks"; | ||
import { createDescendantContext } from "../../util/hooks/descendants/useDescendant"; | ||
import { SlottedDivElementRef } from "./SlottedDivElement"; | ||
|
||
export const [ | ||
VirtualFocusDescendantsProvider, | ||
useVirtualFocusDescendantsContext, | ||
useVirtualFocusDescendantInitializer, | ||
useVirtualFocusDescendant, | ||
] = createDescendantContext< | ||
SlottedDivElementRef, | ||
{ | ||
handleOnSelect: () => void; | ||
handleOnActive: () => void; | ||
} | ||
>(); | ||
|
||
const [VirtualFocusInternalContextProvider, useVirtualFocusInternalContext] = | ||
createContext<{ | ||
virtualFocusIdx: number; | ||
setVirtualFocusIdx: Dispatch<SetStateAction<number>>; | ||
loop: boolean; | ||
uniqueId: string; | ||
role: VirtualFocusProps["role"]; | ||
}>(); | ||
|
||
type VirtualFocusProps = { | ||
children: React.ReactNode; | ||
/** | ||
* The role of the container. This is a limited subset of roles that | ||
* require manual focus management. | ||
* | ||
* Children that are to get focus inside this container element shall be | ||
* pointed to by `aria-activedescendant`. | ||
**/ | ||
role: | ||
| "combobox" | ||
| "grid" | ||
| "listbox" | ||
| "menu" | ||
| "menubar" | ||
| "radiogroup" | ||
| "tree" | ||
| "treegrid" | ||
| "tablist"; | ||
/** | ||
* Whether to cause focus to loop around when it hits the first or last element | ||
* @default true | ||
**/ | ||
loop?: boolean; | ||
}; | ||
|
||
export const VirtualFocus = ({ | ||
children, | ||
role, | ||
loop = false, | ||
}: VirtualFocusProps) => { | ||
const descendants = useVirtualFocusDescendantInitializer(); | ||
const [virtualFocusIdx, setVirtualFocusIdx] = useState(0); | ||
|
||
return ( | ||
<VirtualFocusInternalContextProvider | ||
virtualFocusIdx={virtualFocusIdx} | ||
setVirtualFocusIdx={setVirtualFocusIdx} | ||
loop={loop} | ||
uniqueId={useId().replace(/:/g, "")} | ||
role={role} | ||
> | ||
<VirtualFocusDescendantsProvider value={descendants}> | ||
{children} | ||
</VirtualFocusDescendantsProvider> | ||
</VirtualFocusInternalContextProvider> | ||
); | ||
}; | ||
|
||
export interface VirtualFocusAnchorProps | ||
extends React.HTMLAttributes<HTMLDivElement> { | ||
/** | ||
* The function that is run when the focused element | ||
* is to be selected (eg. do an actual search, change route... etc) | ||
*/ | ||
onSelect: () => void; | ||
/** | ||
* The function that is run when the element gets | ||
* virtual focus set to it. | ||
*/ | ||
onActive: () => void; | ||
children: React.ReactElement; | ||
/** | ||
* Set this to `0` if you want the Anchor container itself | ||
* to be focusable. Since this Anchor is hoisted & merged with | ||
* its first child, you most likely want to keep this as `0`. | ||
* @default 0 | ||
*/ | ||
tabIndex?: number; | ||
} | ||
|
||
/** | ||
* Must have a single child that is an input element. | ||
*/ | ||
export const VirtualFocusAnchor = forwardRef< | ||
HTMLDivElement, | ||
VirtualFocusAnchorProps | ||
>(({ onSelect, onActive, ...rest }, ref) => { | ||
const { virtualFocusIdx, setVirtualFocusIdx, loop, uniqueId, role } = | ||
useVirtualFocusInternalContext(); | ||
|
||
const { register, descendants, index } = useVirtualFocusDescendant({ | ||
handleOnSelect: () => { | ||
onSelect(); | ||
}, | ||
handleOnActive: () => { | ||
setVirtualFocusIdx(0); | ||
onActive(); | ||
}, | ||
}); | ||
|
||
const mergedRefs = useMergeRefs(ref, register); | ||
|
||
return ( | ||
<Slot | ||
id={`virtualfocus-${uniqueId}-${index}`} | ||
role={role} | ||
tabIndex={0} | ||
aria-owns={`virtualfocus-${uniqueId}-content`} | ||
aria-controls={`virtualfocus-${uniqueId}-content`} | ||
aria-activedescendant={`virtualfocus-${uniqueId}-${virtualFocusIdx}`} | ||
ref={mergedRefs} | ||
onKeyDown={(event) => { | ||
if (event.key === "ArrowDown") { | ||
event.preventDefault(); | ||
const to_focus_descendant = descendants.next(virtualFocusIdx, loop); | ||
if (to_focus_descendant) { | ||
to_focus_descendant.handleOnActive(); | ||
} | ||
} else if (event.key === "ArrowUp") { | ||
event.preventDefault(); | ||
const to_focus_descendant = descendants.prev(virtualFocusIdx, loop); | ||
if (to_focus_descendant) { | ||
to_focus_descendant.handleOnActive(); | ||
} | ||
} else if (event.key === "Enter") { | ||
const curr = descendants.item(index); | ||
if (curr?.handleOnSelect) { | ||
curr.handleOnSelect(); | ||
} | ||
} | ||
}} | ||
{...rest} | ||
/> | ||
); | ||
}); | ||
|
||
export interface VirtualFocusContentProps | ||
extends React.HTMLAttributes<HTMLDivElement> {} | ||
|
||
export const VirtualFocusContent = forwardRef< | ||
HTMLDivElement, | ||
VirtualFocusContentProps | ||
>(({ children, ...rest }, ref) => { | ||
const { uniqueId } = useVirtualFocusInternalContext(); | ||
return ( | ||
<div ref={ref} id={`virtualfocus-${uniqueId}-content`} {...rest}> | ||
{children} | ||
</div> | ||
); | ||
}); | ||
|
||
export interface VirtualFocusItemProps | ||
extends React.HTMLAttributes<HTMLDivElement> { | ||
/** | ||
* The function that is run when the element is focused | ||
* (virtually, not actual focus, eg. set a border around an item) | ||
*/ | ||
onActive: () => void; | ||
/** | ||
* The function that is run when the focused element | ||
* is to be selected (eg. do an actual search, change route... etc) | ||
*/ | ||
onSelect: () => void; | ||
children: React.ReactNode; | ||
} | ||
|
||
export const VirtualFocusItem = forwardRef<HTMLElement, VirtualFocusItemProps>( | ||
({ children, onActive, onSelect, ...rest }, ref) => { | ||
const { virtualFocusIdx, setVirtualFocusIdx, uniqueId } = | ||
useVirtualFocusInternalContext(); | ||
const { register, index } = useVirtualFocusDescendant({ | ||
handleOnActive: () => { | ||
setVirtualFocusIdx(index); | ||
onActive(); | ||
}, | ||
handleOnSelect: () => { | ||
onSelect(); | ||
}, | ||
}); | ||
|
||
const mergedRefs = useMergeRefs(ref, register); | ||
|
||
return ( | ||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events | ||
<div | ||
id={`virtualfocus-${uniqueId}-${index}`} | ||
data-aksel-virtualfocus={virtualFocusIdx === index} | ||
ref={mergedRefs} | ||
tabIndex={-1} | ||
onClick={() => { | ||
onSelect(); | ||
}} | ||
onMouseMove={() => { | ||
setVirtualFocusIdx(index); | ||
onActive(); | ||
}} | ||
{...rest} | ||
> | ||
{children} | ||
</div> | ||
); | ||
}, | ||
); | ||
|
||
VirtualFocus.Anchor = VirtualFocusAnchor; | ||
VirtualFocus.Item = VirtualFocusItem; | ||
VirtualFocus.Content = VirtualFocusContent; | ||
|
||
export default VirtualFocus; |
Oops, something went wrong.