Skip to content

Commit

Permalink
Merge 52df526 into e35ddd5
Browse files Browse the repository at this point in the history
  • Loading branch information
JulianNymark committed Jun 12, 2024
2 parents e35ddd5 + 52df526 commit 003cc70
Show file tree
Hide file tree
Showing 5 changed files with 714 additions and 0 deletions.
139 changes: 139 additions & 0 deletions @navikt/core/react/src/form/virtualfocus/RovingFocus.tsx
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 @navikt/core/react/src/form/virtualfocus/SlottedDivElement.tsx
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 @navikt/core/react/src/form/virtualfocus/VirtualFocus.tsx
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;
Loading

0 comments on commit 003cc70

Please sign in to comment.